/*       Copyright (c) Eric Ledoux.  All rights reserved.       */
/* See http://www.dwell.net/terms for code sharing information. */

// UnitTests.cs
//
// CodeDoc unit tests.
//

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Xml;
using DwellNet.CodeDoc;
using CodeDocApi.Properties;

#if DEBUG

namespace DwellNet.CodeDoc
{

/// <summary>
/// Implements CodeDoc unit tests.
/// </summary>
///
public abstract class UnitTests
{
    /// <summary>
    /// Runs unit tests.
    /// </summary>
    ///
    /// <param name="testDataDirPath">The full path to the directory containing
    ///     test data such as Test.cs and TestBase.txt.</param>
    /// 
    /// <param name="testNames">The names of tests to run.  If
    ///     <pr>testNames</pr> is zero-length, run all tests.</param>
    ///
    /// <exception cref="TestFailedException">
    /// A test failed.
    /// </exception>
    ///
    public static void RunTests(string testDataDirPath, string[] testNames)
    {
        // convert <testNames> to a list, <tests>, which will contain the names
        // of tests to run -- we'll delete each test name from the list as we
        // execute each test; any leftover tests indicate argument error;
        // special case: if <testNames> is zero-length, set <tests> to null to
        // indicate that all tests should be run
        List<string> tests =
            (testNames.Length == 0 ? null : new List<string>(testNames));

        // run specified tests
        if (TestSpecified(tests, "SampleCs"))
            RunSampleCsTest(testDataDirPath);
        if (TestSpecified(tests, "Exceptions"))
            RunExceptionTests(testDataDirPath);
        if (TestSpecified(tests, "Other"))
            RunOtherTests();

        // check for any misspelled test names
        if ((tests != null) && (tests.Count > 0))
        {
            throw new TestFailedException(
                "Invalid test name(s): {0}",
                String.Join(", ", tests.ToArray()));
        }

        // done
        Console.WriteLine("\nTests passed.\n");
    }

    /// <summary>
    /// If a list of test names includes a specified test name, all occurences
    /// of that test name are removed from the list and <n>true</n> is
    /// returned.  Otherwise, <n>false</n> is removed.  Special case: if
    /// the list of tests is <n>null</n>, just return <n>true</n> -- in this
    /// case all tests are run.
    /// </summary>
    /// 
    /// <param name="tests">The list of test names.</param>
    /// 
    /// <param name="thisTest">The test name to search for.</param>
    ///
    /// <remarks>
    /// A side effect of this method is that, if successful, a message is
    /// displayed on the console indicating the name of the test being run.
    /// </remarks>
    ///
    public static bool TestSpecified(List<string> tests, string thisTest)
    {
        // special case: if <tests> is null, run all tests
        if (tests == null)
        {
            Console.WriteLine("Running test: {0}", thisTest);
            return true;
        }

        // look for <thisTest> in <tests>
        foreach (string test in tests)
        {
            if (test == thisTest)
            {
                tests.RemoveAll(
                    delegate(string test2) { return (test == test2); });
                Console.WriteLine("Running test: {0}", thisTest);
                return true;
            }
        }

        // <thisTest> not found
        return false;
    }

    /// <summary>
    /// Runs a unit test that loads Test.cs into a <r>SourceFile</r>,
    /// calls <r>Topic.Dump</r> for each <r>Topic</r> in the <r>SourceFile</r>,
    /// and compares the result with the result from a previously known-correct
    /// run, TestBase.txt.
    /// </summary>
    ///
    /// <param name="testDataDirPath">The full path to the directory containing
    ///     test data such as Test.cs and TestBase.txt.</param>
    ///
    /// <exception cref="TestFailedException">
    /// A test failed.
    /// </exception>
    ///
    static void RunSampleCsTest(string testDataDirPath)
    {
        // load Test.cs into <sourceFile>
        DocumentationSet docSet = new DocumentationSet();
        string sampleSourceFilePath =
            MakeFilePath(testDataDirPath, "Test.cs");
        SourceFile sourceFile = docSet.AddSourceFile(sampleSourceFilePath,
            Path.GetFileName(sampleSourceFilePath), null);
        Debug.Assert(sourceFile != null);

        // check for warnings in loaded source file
        foreach (ParsingException ex in sourceFile.Warnings)
            Console.Error.WriteLine(ex.WarningMessage);
        if (sourceFile.Warnings.Count > 0)
            throw new TestFailedException("Warnings generated");

        // dump data about the topics in Test.cs into TestRun.txt
        string sampleRunFilePath =
            Path.Combine(testDataDirPath, "TestRun.txt");
        DumpTopics(sourceFile, sampleRunFilePath);

        // compare TestBase.txt with TestRun.txt -- if they differ, either
        // there's a bug or TestBase.txt needs to be updated
        string sampleBaseFilePath =
            MakeFilePath(testDataDirPath, "TestBase.txt");
        if (!AreTextFilesEqual(sampleBaseFilePath, sampleRunFilePath))
        {
            throw new TestFailedException("Files not equal: {0}, {1}",
                sampleBaseFilePath, sampleRunFilePath);
        }
    }

    /// <summary>
    /// Runs unit tests that attempt to generate each exception thrown by this
    /// DLL.
    /// </summary>
    ///
    /// <param name="testDataDirPath">The full path to the directory containing
    ///     unit test data.</param>
    ///
    /// <exception cref="TestFailedException">
    /// A test failed.
    /// </exception>
    ///
    static void RunExceptionTests(string testDataDirPath)
    {
        string exceptionsXmlPath =
            MakeFilePath(testDataDirPath, "Exceptions.xml");
        using (XmlReader outerReader = XmlReader.Create(exceptionsXmlPath))
        {
            // loop once for each "<Test>" element in Exceptions.xml
            IXmlLineInfo lineInfo = (IXmlLineInfo) outerReader;
            outerReader.ReadToFollowing("Tests");
            outerReader.MoveToAttribute("RunNewOnly");
            bool runNewOnly = outerReader.ReadContentAsBoolean();
            if (runNewOnly)
                Console.WriteLine("\n** WARNING: RunNewOnly=\"true\"");
            outerReader.MoveToContent();
            outerReader.ReadToDescendant("Test");
            while (true)
            {
                // skip this "<Test>" element if it has New="true" specified
                // and the root "<Tests>" element had RunNewOnly="true" --
                // this is used during test development
                bool isNew = outerReader.MoveToAttribute("New");
                if (isNew)
                {
                    isNew = outerReader.ReadContentAsBoolean();
                    outerReader.MoveToContent();
                }
                if (runNewOnly && !isNew)
                {
                    if (!outerReader.ReadToNextSibling("Test"))
                        break;
                    continue;
                }

                // parse and process this "<Test>" element
                int lineNumber = lineInfo.LineNumber;
                using (XmlReader testReader = outerReader.ReadSubtree())
                {
                    // set <source> and <expectedExceptionMessage> to the
                    // contents of the "<Source>" and "<Exception>" elements,
                    // respectively; set <expectedExceptionType> to the "Type"
                    // attribute of "<Exception>", converted to a CLR type
                    testReader.ReadToDescendant("Source");
                    string source = testReader.ReadElementContentAsString()
                        .Trim();
                    testReader.ReadToNextSibling("Exception");
                    testReader.MoveToAttribute("Type");
                    string expectedExceptionTypeString =
                        testReader.ReadContentAsString();
                    testReader.MoveToContent();
                    string expectedExceptionMessage =
                        testReader.ReadElementContentAsString().Trim();
                    Type expectedExceptionType =
                        Type.GetType(expectedExceptionTypeString);

                    // execute the test, and set <actualExceptionType> and
                    // <actualExceptionMessage> to the exception type and
                    // message (if any) thrown by the code
                    Type actualExceptionType = null;
                    string actualExceptionMessage = null;
                    CSharpSourceFile sourceFile;
                    try
                    {
                        // load the C# snippet from "<Source>" into
                        // <sourceFile>
                        DocumentationSet docSet = new DocumentationSet();
                        sourceFile = new CSharpSourceFile(docSet,
                            String.Format("{0}({1})", exceptionsXmlPath,
                                lineNumber), null);
                        using (StringReader reader = new StringReader(source))
                            sourceFile.Load(reader);

                        // perform preprocessing of conditional compilation 
                        // constructs like #if
                        sourceFile.Preprocess(null);

                        // locate documentation topics within <sourceFile>
                        sourceFile.LocateTopics();
                    }
#if true // set to false TEMPORARILY ONLY to debug problems
                    catch (Exception ex)
                    {
                        actualExceptionType = ex.GetType();
                        actualExceptionMessage = ex.Message;
                        sourceFile = null;
                    }
#else
                    #warning This is a special diagnostic build
                    catch (DivideByZeroException) // random exception
                    {
                    }
#endif

                    // check for warnings in loaded source file
                    if (sourceFile != null)
                    {
                        foreach (ParsingException ex in sourceFile.Warnings)
                            Console.Error.WriteLine(ex.WarningMessage);
                        if (sourceFile.Warnings.Count > 0)
                            throw new TestFailedException("Warnings generated");
                    }

                    // check the results of the test
                    bool typePassed =
                        (actualExceptionType == expectedExceptionType);
                    if (actualExceptionMessage == null)
                        actualExceptionMessage = "";
                    bool messagePassed =
                        (expectedExceptionMessage.Length >= 5) &&
                        (actualExceptionMessage.IndexOf(
                            expectedExceptionMessage) >= 0);
                    if (!typePassed || !messagePassed)
                    {
                        StringBuilder text = new StringBuilder(2000);
                        text.AppendFormat(
                            "Exception unit test failed: {0}({1}):\n",
                            exceptionsXmlPath, lineNumber);
                        if (!typePassed)
                        {
                            text.AppendFormat(
                                "Expected exception type:    {0}\n" +
                                "Actual exception type:      {1}\n",
                                expectedExceptionType.ToString(),
                                ((actualExceptionType == null) ? "(none)" :
                                    actualExceptionType.ToString()));
                        }
                        if (!messagePassed)
                        {
                            text.AppendFormat(
                                "Expected exception message: {0}\n" +
                                "Actual exception message:   {1}\n",
                                expectedExceptionMessage,
                                actualExceptionMessage ?? "(none)");
                        }
                        throw new TestFailedException("{0}", text.ToString());
                    }
                }

                // advance to the next "<Test>" element
                if (!outerReader.ReadToNextSibling("Test"))
                    break;
            }
        }
    }

    /// <summary>
    /// Runs miscellaneous unit tests.
    /// </summary>
    ///
    static void RunOtherTests()
    {
        // test an incorrect way to call SourceFile.New()
        Test.FailUnlessException(delegate()
        {
            DocumentationSet docSet = new DocumentationSet();
            docSet.AddSourceFile("Foo.cs", "Foo.vb", null);
        }, typeof(ArgumentException),
            "Inconsistent extensions between parameters");

        // test another incorrect way to call SourceFile.New()
        Test.FailUnlessException(delegate()
        {
            DocumentationSet docSet = new DocumentationSet();
            docSet.AddSourceFile("Foo.xy", "Foo.xy", null);
        }, typeof(ArgumentException),
            "Source file extension \".xy\" not supported: Foo.xy");
    }

    /// <summary>
    /// Updates unit test data files, assuming that current functionality is
    /// correct.  The updated files are used by subsequent runs of
    /// <r>RunTests</r>.
    /// </summary>
    ///
    /// <param name="testDataDirPath">The full path to the directory containing
    ///     test data such as Test.cs and TestBase.txt.</param>
    ///
    public static void UpdateTests(string testDataDirPath)
    {
        // load Test.cs into <sourceFile>
        DocumentationSet docSet = new DocumentationSet();
        string sampleSourceFilePath =
            MakeFilePath(testDataDirPath, "Test.cs");
        SourceFile sourceFile = docSet.AddSourceFile(sampleSourceFilePath,
            Path.GetFileName(sampleSourceFilePath), null);
        Debug.Assert(sourceFile != null);

        // check for warnings in loaded source file
        foreach (ParsingException ex in sourceFile.Warnings)
            Console.Error.WriteLine(ex.WarningMessage);

        // dump data about the topics in Test.cs into TestBase.txt
        string sampleBaseFilePath =
            Path.Combine(testDataDirPath, "TestBase.txt");
        DumpTopics(sourceFile, sampleBaseFilePath);
    }

    /// <summary>
    /// Dumps information about the topics in a given source file into a given
    /// output text file.
    /// </summary>
    ///
    /// <param name="sourceFile">The source file containing the topics to
    ///     dump.</param>
    ///
    /// <param name="dumpFilePath">The path to the output text file.</param>
    ///
    static void DumpTopics(SourceFile sourceFile, string dumpFilePath)
    {
        using (StreamWriter dumpFile = new StreamWriter(dumpFilePath))
        {
            foreach (Topic topic in sourceFile.Topics)
                dumpFile.WriteLine(topic.Dump(int.MaxValue));
        }
    }

    /// <summary>
    /// Returns <n>true</n> if two text files are equal, <n>false</n> if not.
    /// </summary>
    ///
    /// <param name="path1">The path to the first text file.</param>
    ///
    /// <param name="path2">The path to the second text file.</param>
    ///
    static bool AreTextFilesEqual(string path1, string path2)
    {
        string file1, file2;
        using (StreamReader stream1 = new StreamReader(path1))
            file1 = stream1.ReadToEnd();
        using (StreamReader stream2 = new StreamReader(path2))
            file2 = stream2.ReadToEnd();
        return (file1 == file2);
    }

    /// <summary>
    /// Returns a full path to a file given a path relative to a given
    /// directory.  Throws an application exception if
    /// path relative to the current directory.
    /// </summary>
    ///
    /// <param name="dirPath">The full path to the directory that
    ///     <pr>fileName</pr> is relative to.</param>
    ///
    /// <param name="fileName">The path to the desired file, relative to
    ///     <pr>dirPath</pr>.</param>
    ///
    /// <exception cref="FileNotFoundException">
    /// The specified file doesn't exist.
    /// </exception>
    ///
    static string MakeFilePath(string dirPath, string fileName)
    {
        string filePath = Path.Combine(dirPath, fileName);
        if (!File.Exists(filePath))
        {
            throw new FileNotFoundException(
                String.Format(Resources.FileNotFound, filePath), filePath);
        }
        return filePath;
    }

    /// <summary>
    /// Indicates that a unit test failed.
    /// </summary>
    ///
    public class TestFailedException : Exception
    {
        /// <summary>
        /// Initializes an instance of this class.
        /// </summary>
        ///
        /// <param name="format">A formatting string for an error message to
        ///     include with the exception.</param>
        ///
        /// <param name="args">Formatting arguments for the error message.
        ///     </param>
        ///
        public TestFailedException(string format, params object[] args) :
            base(String.Format(format, args))
        {
        }
    }
}

/// <summary>
/// Helper methods for implementing unit tests.
/// </summary>
///
public class Test
{
    /// <summary>
    /// Calls <r>Fail</r> if a given condition is true.
    /// </summary>
    ///
    /// <param name="condition">The condition to test.</param>
    ///
    public static void FailIf(bool condition)
    {
        if (condition)
            HelpFail("condition failure", 2);
    }

    /// <summary>
    /// Calls <r>Fail</r> if two values are equal.
    /// </summary>
    /// 
    /// <typeparam name="T">The type of the values to compare.</typeparam>
    /// 
    /// <param name="value1">The first value to compare.</param>
    /// 
    /// <param name="value2">The second value to compare.</param>
    ///
    public static void FailIfEqual<T>(T value1, T value2)
    {
        if (value1.Equals(value2))
        {
            HelpFail(String.Format(
                "condition failure ([{0}] == [{1}])", value1, value2), 2);
        }
    }

    /// <summary>
    /// Calls <r>Fail</r> if two values are not equal.
    /// </summary>
    /// 
    /// <typeparam name="T">The type of the values to compare.</typeparam>
    /// 
    /// <param name="value1">The first value to compare.</param>
    /// 
    /// <param name="value2">The second value to compare.</param>
    ///
    public static void FailIfNotEqual<T>(T value1, T value2)
    {
        if (!value1.Equals(value2))
        {
            HelpFail(String.Format(
                "condition failure ([{0}] != [{1}])", value1, value2), 2);
        }
    }

    /// <summary>
    /// Assuming a test failed, display a message which, in debug builds,
    /// includes the location of the failure.  Then, exit the application.
    /// </summary>
    ///
    public static void Fail()
    {
        HelpFail("condition failure", 2);
    }

    /// <summary>
    /// Calls <r>Fail</r> if a given delegate doesn't throw a specified
    /// exception.
    /// </summary>
    /// 
    /// <param name="del">The delegate t call.</param>
    /// 
    /// <param name="exceptionType">The expected exception type.</param>
    ///
    public static void FailUnlessException(VoidDelegate del, Type exceptionType)
    {
        FailUnlessException(del, exceptionType, null);
    }

    /// <summary>
    /// Calls <r>Fail</r> if a given delegate doesn't throw a specified
    /// exception with exception text containing a given string.
    /// </summary>
    /// 
    /// <param name="del">The delegate t call.</param>
    /// 
    /// <param name="exceptionType">The expected exception type.</param>
    /// 
    /// <param name="exceptionPattern">Regular expression that the exception
    ///     message is expected to match, or null if no specific exception
    ///     text is expected.</param>
    ///
    public static void FailUnlessException(VoidDelegate del,
        Type exceptionType, string exceptionPattern)
    {
        try
        {
            del();
            HelpFail("missing exception", 2);
        }
        catch (Exception ex)
        {
            if (ex.GetType() != exceptionType)
            {
                HelpFail(String.Format(
                    "wrong exception: {0}", ex.GetType()), 2);
            }
            else
            if (exceptionPattern != null)
            {
                string message = (ex.Message == null) ? "" : ex.Message;
                if (!Regex.Match(message, exceptionPattern,
                    RegexOptions.Singleline).Success)
                {
                    HelpFail(String.Format(
                        "wrong exception text: \"{0}\"", message), 2);
                }
            }
        }
    }

    /// <summary>
    /// Helps implement <r>Fail</r> and associated methods.
    /// </summary>
    ///
    /// <param name="message">The message to display.</param>
    /// 
    /// <param name="skipFrames">The value of <i>skipFrames</i> to pass to the
    ///     <n>StackFrame</n> constructor.</param>
    ///
    private static void HelpFail(string message, int skipFrames)
    {
        // display information about the failed test
        string wholeMessage;
        StackFrame stackFrame = new StackFrame(skipFrames, true);
        string fileName = stackFrame.GetFileName();
        if (fileName == null)
        {
            // no debug information available (e.g. .pdb file missing)
            wholeMessage = String.Format("{0} (no debug info)", message);
        }
        else
        {
            // debug information available
            int lineNumber = stackFrame.GetFileLineNumber();
            wholeMessage = String.Format("{0}: {1}({2})", message, fileName,
                lineNumber);
        }
        throw new UnitTests.TestFailedException("{0}", wholeMessage);
    }

    /// <summary>
    /// Waits for an asynchronous test to complete.
    /// </summary>
    ///
    /// <param name="waitHandle">The test code (presumably running on another
    ///     thread) will call <n>Set</n> on this <r>WaitHandle</r> when the
    ///     test completes successfully.  If the test fails, the test code will
    ///     call <r>Fail</r> (or a variant), which will end the application.
    ///     </param>
    ///
    /// <param name="timeout">After this many milliseconds of waiting, this
    ///     method will stop waiting and perform the same action as if
    ///     <r>Fail</r> had been called instead of this method.  Note that if
    ///     <c>DEBUG_UNIT_TESTS</c> is #define'd then <n>Timeout.Infinite</n>
    ///     is used instead of <pr>timeout</pr>.</param>
    ///
    public static void WaitForTestToComplete(WaitHandle waitHandle,
        int timeout)
    {
        if (!waitHandle.WaitOne(
#if DEBUG_UNIT_TESTS
                Timeout.Infinite,
#else
                timeout,
#endif
                false))
            HelpFail("Test timed out", 2);
    }

    /// <summary>
    /// Formats unit test output to log as trace output, providing the
    /// conditional compilation symbol "DEBUG_UNIT_TESTS" is defined.
    /// </summary>
    ///
    /// <param name="format">The format string.</param>
    ///
    /// <param name="args">Formatting arguments.</param>
    ///
    /// <remarks>
    /// <pr>format</pr> and <pr>args</pr> are used in the
    /// same was as in <n>String.Format</n>.
    /// </remarks>
    ///
    [Conditional("DEBUG_UNIT_TESTS")]
    public static void Trace(string format, params object[] args)
    {
        System.Diagnostics.Trace.WriteLine(string.Format(format, args));
    }

    /// <summary>
    /// A method with no parameters and no return value.
    /// </summary>
    ///
    public delegate void VoidDelegate();
}

}

#endif