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
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