# Perforce Defect Tracking Integration Project # # # 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(" *\\(P4DTI-[0-9]+[0-9X]\\) .*") # Regexp which identifies correctly formatted header lines. header_re = re.compile(' *\\(P4DTI-\\1\\) +(.*[^ ]) *' '') # 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; . # # # 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 $