# Perforce Defect Tracking Integration Project
# <http://www.ravenbrook.com/project/p4dti/>
#
# TEST_CATALOG.PY -- UNIT TEST FOR CATALOG MODULE
#
# Gareth Rees, Ravenbrook Limited, 2001-03-14
#
#
# 1. INTRODUCTION
#
# This module defines a unit test for the catalog module. It checks
# that the message catalog is used consistently, correctly and
# completely throughout the P4DTI.
#
# It uses the PyUnit unit test framework [PyUnit].
#
# The intended readership is project developers.
#
# This document is not confidential.
#
#
# 1.1. Regression tests in this script
#
# The section * means that the defect is tested throughout as a simple
# consequence of running the script; there is no particular test for it.
#
# Job Section Title
# ----------------------------------------------------------------------
# job000303 2.3 Incorrect message catalog use generates obscure
# errors
import os
import sys
p4dti_path = os.path.join(os.getcwd(), os.pardir, 'code', 'replicator')
if p4dti_path not in sys.path:
sys.path.append(p4dti_path)
import catalog
import dircache
import message
import p4dti_unittest
import parser
import re
import string
import unittest
# 2. TEST CASES
# 2.1. Use of the message catalog
#
# This test case checks that the P4DTI English message catalog is used
# consistently, correctly and completely.
#
# It reads all Python files in the replicator. In each file, it finds
# all occurrences where a message is fetched from the catalog. For each
# occurrence, it checks that:
#
# 1. The message is in the catalog;
#
# 2. The catalog gives a legal priority for the message;
#
# 3. There's a comment preceding the use of the message which gives the
# correct text; and
#
# 4. The correct number of arguments are supplied.
#
# Finally it checks that all unused messages in the catalog have
# priority message.NOT_USED.
class use(p4dti_unittest.TestCase):
# The directory in which to search for Python files.
dir = os.path.join(os.getcwd(), os.pardir, 'code', 'replicator')
# Regexp matching the use of a message. Group 2 is the message
# number. Group 3 is the start of the arguments to the message
# constructor.
msg_re = re.compile("(\\.log|\\bcatalog\\.msg)\\(([0-9]+)"
",? *(\\)|.*)")
# Regexp matching the first line in a block comment that precedes
# the use of a message: the comment starts with a double quote.
comment_start_re = re.compile("^[ \t]*# *\"")
# Regexp matching any line in a block comment.
comment_re = re.compile("^[ \t]*# *(.*)$")
# Messages found
messages = {}
# Return the length of a tuple specified by a string, for example
# "(1,2)" -> 2, "(foo(1), bar(2,3), baz)" -> 3. Use the Python
# parser to work this out accurately.
def tuple_length(self, tuple):
parse_tree = parser.expr(tuple).tolist()
# The following expression burrows down through the parse tree
# to the point where the elements of the tuple are represented.
# There are exactly twice as many elements in the parse tree as
# there are items in the tuple.
return len(parse_tree[1][1][1][1][1][1][1][1][1][1][1][1][1][1]
[2]) / 2
def runTest(self):
"Use of the message catalog (test_catalog.use)"
# Find Python files and check them.
files = filter(lambda f: f[-3:] == '.py',
dircache.listdir(self.dir))
self.failUnless(files, "No Python source files in '%s'."
% self.dir)
for file in files:
self.check_file(file)
# Check that all unused messages have priority message.NOT_USED.
for (id, (priority, _)) in catalog.p4dti_en_catalog.items():
if not self.messages.has_key(id):
if priority != message.NOT_USED:
self.addFailure("Found no occurrence of message "
"%d, but its priority is not "
"message.NOT_USED." % id)
def check_file(self, file):
lines = open(os.path.join(self.dir, file), 'r').readlines()
# Search forward for lines containing "catalog.msg([0-9]+" or
# ".log([0-9]+".
for l in range(len(lines)):
match = self.msg_re.search(lines[l])
if match:
msgid = int(match.group(2))
arg_source = match.group(3)
self.messages[msgid] = 1
# Check that the message exists.
if not catalog.p4dti_en_catalog.has_key(msgid):
self.addFailure("File %s, line %d uses message %d "
"but this is missing from the "
"catalog." % (file, l+1, msgid))
# Check that priority is legal. Note the nonintuitive
# sense of the comparisons: higher priorites have lower
# numbers.
priority, message_text = catalog.p4dti_en_catalog[msgid]
if priority > message.DEBUG or priority < message.EMERG:
self.addFailure("File %s, line %d uses message %d, "
"but this has priority %d, which "
"is out of range."
% (file, l+1, msgid, priority))
# Check that there's a comment preceding the message
# which gives the correct text.
m = l - 1
while (m >= 0
and not self.comment_start_re.match(lines[m])):
m = m - 1
if m < 0:
self.addFailure("File %s, line %d uses message %d "
"but there's no preceding comment "
"with the message text."
% (file, l+1, msgid))
comment_lines = []
for n in range(m,l):
match = self.comment_re.match(lines[n])
if match:
comment_lines.append(match.group(1))
else:
self.addFailure("File %s, line %d uses message "
"%d but is preceded by "
"non-comment line %d."
% (file, l+1, msgid, n+1))
comment_text = string.join(comment_lines, " ")[1:-1]
# Ignore whitespace when comparing.
message_text = re.sub('\s+', ' ', message_text)
comment_text = re.sub('\s+', ' ', comment_text)
if comment_text != message_text:
self.addFailure("File %s, line %d uses message "
"%d. The preceding comment should "
"say '%s', but actually says '%s'."
% (file, l+1, msgid, message_text,
comment_text))
# Collect source code until we find the closing paren.
# Number of open parens we've seen so far.
parens = 1
# Start of arguments.
start = 0
# Where we've got to in arg_source.
i = 0
# Where we've got to in lines.
m = l
while parens > 0:
if i >= len(arg_source):
m = m + 1
arg_source = arg_source + lines[m]
if arg_source[i] in '([{':
parens = parens + 1
elif arg_source[i] in '}])':
parens = parens - 1
i = i + 1
if arg_source[start] in string.whitespace:
start = i
args = arg_source[start:i-1]
# Check that the correct number of arguments have been
# passed.
format_args = re.findall("%.", message_text)
expected_nargs = len(filter(lambda s: s != "%%",
format_args))
if args:
if re.match("[\t\n ]*\\(", args):
try:
found_nargs = self.tuple_length(args)
except:
self.addFailure("Couldn't parse file %s,"
"line %d." % (file, l+1))
else:
found_nargs = 1
else:
found_nargs = 0
if expected_nargs != found_nargs:
self.addFailure("File %s, line %d uses message %d "
"with %d argument%s, but that "
"message requires %d argument%s."
% (file, l+1, msgid, found_nargs,
['s', ''][found_nargs == 1],
expected_nargs,
['s', ''][expected_nargs == 1]))
# 2.2. Messages in the Administrator's Guide
#
# This test case reads the Administrator's Guide and checks that:
#
# 1. Each message has an anchor called message-P4DTI-N, formatted
# correctly.
#
# 2. The check digit is correct.
#
# 3. The text of the message in the AG matches the catalog.
#
# 4. All errors appear in the AG.
class ag(p4dti_unittest.TestCase):
# The location of the AG.
ag_filename = os.path.join(os.pardir, 'manual', 'ag', 'index.html')
# Messages found in the AG.
messages = {}
# Regexp matching header lines which introduce a message.
message_re = re.compile("<a.*> *\\(P4DTI-[0-9]+[0-9X]\\) .*</a>")
# Regexp which identifies correctly formatted header lines.
header_re = re.compile('<a id="message-P4DTI-(([0-9]+)([0-9X]))'
'(-[0-9]+)?" name="message-P4DTI-\\1'
'(-[0-9]+)?"> *\\(P4DTI-\\1\\) +(.*[^ ]) *'
'</a>')
# These are messages that have parameters substituted when they
# appear in the AG, so shouldn't be checked literally.
exempted_messages = [708, 891]
# These are messages that don't need to appear in the AG.
non_appearing_messages = [
# Major bugs in the P4DTI.
833, 840, 905,
# Major bugs in Perforce.
837, 838, 839, 896, 897, 898, 899, 904,
# Self-explanatory (script output or has reference to AG).
914, 1001, 1008, 1101, 1102, 1103,
# Comes with another error anyway.
1018, 1019, 1020,
]
def analyze_line(self, line):
# Check that the header is in the right format.
match = self.header_re.search(line)
if not match:
self.addFailure("Message header line badly formatted: "
+ line)
return
# Check that the check digit is correct.
id = int(match.group(2))
msg = message.message(id, "None", message.INFO, "None")
if match.group(3) != msg.check_digit():
self.addFailure("Message %d has check digit %s in AG (not "
"%s)."
% (id, match.group(3), msg.check_digit()))
# Check that the message text is correct (excepting messages
# that are exempted).
if id not in self.exempted_messages:
expected_text = catalog.p4dti_en_catalog[id][1]
if match.group(6) != expected_text:
self.addFailure("Message %d has text '%s' in AG (not "
"'%s')."
% (id, match.group(6), expected_text))
# Record the discovery of the message
self.messages[id] = 1
def runTest(self):
"Messages in the Administrator's Guide (test_catalog.ag)"
ag = open(self.ag_filename, 'r')
for line in ag.readlines():
if self.message_re.search(line):
self.analyze_line(line)
ag.close()
# Check that all errors appear in the manual.
missing_messages = []
not_used_messages = []
for (id, (priority, text)) in catalog.p4dti_en_catalog.items():
if (priority >= message.EMERG and priority <= message.ERR
and not id in self.non_appearing_messages
and not self.messages.has_key(id)):
missing_messages.append(id)
elif (priority == message.NOT_USED
and self.messages.has_key(id)):
not_used_messages.append(id)
missing_messages.sort()
not_used_messages.sort()
self.failIf(missing_messages,
"These error messages are missing from the AG: %s."
% missing_messages)
self.failIf(not_used_messages,
"These error messages are in the AG despite being NOT_USED: %s."
% not_used_messages)
# 2.3. Passing invalid arguments to catalog.new()
#
# This is a regression test for job000303.
class args(unittest.TestCase):
def runTest(self):
"Invalid arguments to catalog.new (test_catalog.args)"
factory = message.catalog_factory({}, "Test")
id = "No such message"
msg = factory.new(id)
expected = ("(Test-00) No message with id '%s' (args = ())."
% id)
assert str(msg) == expected, \
"Expected '%s' but found '%s'." % (expected, str(msg))
factory = message.catalog_factory({1:(message.ERR,'%d%d')},
"Test")
msg = factory.new(1, ('foo',))
expected = ("(Test-00) Message 1 has format string '%d%d' "
"but arguments ('foo',).")
assert str(msg) == expected, \
"Expected '%s' but found '%s'." % (expected, str(msg))
msg = factory.new(1, (1,2,3))
expected = ("(Test-00) Message 1 has format string '%d%d' "
"but arguments (1, 2, 3).")
assert str(msg) == expected, \
"Expected '%s' but found '%s'." % (expected, str(msg))
# 3. RUNNING THE TESTS
def tests():
suite = unittest.TestSuite()
for t in [ag, args, use]:
suite.addTest(t())
return suite
if __name__ == "__main__":
unittest.main(defaultTest="tests")
# A. REFERENCES
#
# [PyUnit] "PyUnit - a unit testing framework for Python"; Steve
# Purcell; <http://pyunit.sourceforge.net/>.
#
#
# B. DOCUMENT HISTORY
#
# 2001-03-14 GDR Created.
#
# 2001-03-16 GDR Added ag_messages test case.
#
# 2001-04-24 GDR Use p4dti_unittest to collect many failures per test
# case. Use os.path so tests are independent of operating system.
#
# 2001-05-22 GDR Added invalid_id test case.
#
# 2001-06-14 GDR Added message 891 to exempted_messages.
#
# 2001-07-17 GDR Some messages don't need to appear in the AG.
#
# 2001-07-23 GDR Report failures with file name and line number.
#
# 2002-02-04 GDR Catalog use test can cope with message arguments split
# over several lines.
#
#
# C. COPYRIGHT AND LICENSE
#
# This file is copyright (c) 2001 Perforce Software, Inc. All rights
# reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
# 1. Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#
# 2. Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in
# the documentation and/or other materials provided with the
# distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
# OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
# TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH
# DAMAGE.
#
#
# $Id: //info.ravenbrook.com/project/p4dti/version/2.1/test/test_catalog.py#1 $