#                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 4 is the arguments to the message, if any.
    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))
                self.messages[msgid] = 1
                args = match.group(4)

                # 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 not match:
                        self.addFailure("File %s, line %d uses message %d but "
                                        "is preceded by non-comment line %d."
                                        % (file, l+1, msgid, n+1))
                    comment_lines.append(match.group(1))
                comment_text = string.join(comment_lines, " ")[1:-1]
                # Ignore whitespace when comparing.
                message_text = re.sub('  +', ' ', message_text)
                comment_text = re.sub('  +', ' ', 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))

                # Check that the correct number of arguments have been passed.
                expected_nargs = len(filter(lambda s: s != "%%",
                                            re.findall("%.", message_text)))
                if args:
                    if re.match("^ *\\(", 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 = [
        318, 833, 838, 839, # Can't happen.
        620, # Seriously corrupt TeamTrack database.
        706, # Perforce error message.
        891, # Defect tracker error message.
        1001, # Self-explanatory.
        ]

    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 = []
        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)
        missing_messages.sort()
        self.failIf(missing_messages,
                    "These error messages are missing from the AG: %s."
                    % missing_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.
#
#
# C. COPYRIGHT AND LICENCE
#
# 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/1.1/test/test_catalog.py#8 $
