#             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/branch/2008-05-06/unicode/test/test_catalog.py#1 $
