diff --git a/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs new file mode 100644 index 0000000..5c891b8 --- /dev/null +++ b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs @@ -0,0 +1,64 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace IntelliTect.TestTools.Console.Tests; + +[TestClass] +public class EnhancedErrorMessageTests +{ + /// + /// Runs via , asserts it + /// throws , and returns the exception message. + /// + private static string GetMismatchMessage(string expected, Action consoleAction) + { + var ex = Assert.ThrowsExactly( + () => ConsoleAssert.ExpectLike(expected, consoleAction)); + return ex.Message; + } + + [TestMethod] + public void ExpectLike_WildcardMismatch_ShowsDetailedDiff() + { + string errorMessage = GetMismatchMessage( + "PING *(::1) 56 data bytes\n64 bytes from *", + () => System.Console.Write("PING localhost(::1) WRONG data bytes\n64 bytes from localhost")); + + StringAssert.Contains(errorMessage, "Line-by-line comparison"); + StringAssert.Contains(errorMessage, "❌"); + } + + [TestMethod] + public void ExpectLike_ExtraLines_IdentifiedAsSuch() + { + string errorMessage = GetMismatchMessage( + "Line 1", + () => + { + System.Console.WriteLine("Line 1"); + System.Console.WriteLine("Line 2"); + }); + + StringAssert.Contains(errorMessage, "Line 2: ❌"); + StringAssert.Contains(errorMessage, "Unexpected extra line"); + } + + [TestMethod] + public void ExpectLike_MissingLines_IdentifiedAsSuch() + { + string errorMessage = GetMismatchMessage( + "Line 1\nLine *", + () => System.Console.WriteLine("Line 1")); + + StringAssert.Contains(errorMessage, "Missing line"); + } + + [TestMethod] + public void ExpectLike_SuccessfulWildcardMatch_DoesNotThrow() + { + ConsoleAssert.ExpectLike("Hello * world", () => + { + System.Console.Write("Hello beautiful world"); + }); + } +} diff --git a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs new file mode 100644 index 0000000..d20ae38 --- /dev/null +++ b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs @@ -0,0 +1,220 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace IntelliTect.TestTools.Console.Tests; + +[TestClass] +public class WildcardMatchAnalyzerTests +{ + /// + /// Calls then + /// and asserts the result is non-empty. + /// + private static string GetDiff(string expected, string actual) + { + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + string diff = WildcardMatchAnalyzer.GenerateDetailedDiff(results); + Assert.IsFalse(string.IsNullOrEmpty(diff)); + return diff; + } + + [TestMethod] + public void AnalyzeMatch_SingleLineMatch_IdentifiesWildcardMatches() + { + // Arrange + string expected = "Hello * world"; + string actual = "Hello beautiful world"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.AreEqual(1, results[0].WildcardMatches.Count); + // The * matches "beautiful" (without trailing space because "world" comes next) + Assert.AreEqual("beautiful", results[0].WildcardMatches[0].MatchedText); + } + + [TestMethod] + public void AnalyzeMatch_MultipleWildcards_TracksAllMatches() + { + // Arrange + string expected = "PING *(* (::1)) * data bytes"; + string actual = "PING localhost(localhost (::1)) 56 data bytes"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.IsTrue(results[0].WildcardMatches.Count >= 2); + } + + [TestMethod] + public void AnalyzeMatch_Mismatch_IdentifiesFailure() + { + // Arrange + string expected = "Hello world"; + string actual = "Hello universe"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(1, results.Count); + Assert.IsFalse(results[0].IsMatch); + } + + [TestMethod] + public void AnalyzeMatch_ExtraLinesInActual_MarkedAsUnexpected() + { + // Arrange + string expected = "Line 1\nLine 2"; + string actual = "Line 1\nLine 2\nLine 3"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(3, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.IsTrue(results[1].IsMatch); + Assert.IsFalse(results[2].IsMatch); // Extra line + Assert.IsNull(results[2].ExpectedLine); + Assert.IsNotNull(results[2].ActualLine); + } + + [TestMethod] + public void AnalyzeMatch_MissingLinesInActual_MarkedAsMissing() + { + // Arrange + string expected = "Line 1\nLine 2\nLine 3"; + string actual = "Line 1\nLine 2"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(3, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.IsTrue(results[1].IsMatch); + Assert.IsFalse(results[2].IsMatch); // Missing line + Assert.IsNotNull(results[2].ExpectedLine); + Assert.IsNull(results[2].ActualLine); + } + + [TestMethod] + public void GenerateDetailedDiff_CreatesReadableOutput() + { + string diff = GetDiff("Hello * world\nLine *", "Hello beautiful world\nLine 2"); + + StringAssert.Contains(diff, "Line-by-line comparison"); + StringAssert.Contains(diff, "✅"); + StringAssert.Contains(diff, "Wildcard matches"); + } + + [TestMethod] + public void GenerateDetailedDiff_WithMismatch_ShowsFailure() + { + string diff = GetDiff("Expected text", "Actual text"); + + StringAssert.Contains(diff, "❌"); + } + + [TestMethod] + public void AnalyzeMatch_EmptyStrings_HandlesGracefully() + { + // Arrange & Act + var results = WildcardMatchAnalyzer.AnalyzeMatch("", ""); + + // Assert - Empty strings result in no lines to compare + Assert.AreEqual(0, results.Count); + } + + [TestMethod] + public void AnalyzeMatch_QuestionMarkWildcard_TracksMatch() + { + // Arrange + string expected = "test?"; + string actual = "test1"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.AreEqual(1, results[0].WildcardMatches.Count); + Assert.AreEqual("?", results[0].WildcardMatches[0].Pattern); + Assert.AreEqual("1", results[0].WildcardMatches[0].MatchedText); + } + + [TestMethod] + public void AnalyzeMatch_QuestionMarkWildcard_FailsOnTooManyChars() + { + // '?' matches exactly one character — "test12" has two chars after "test" + var results = WildcardMatchAnalyzer.AnalyzeMatch("test?", "test12"); + + Assert.AreEqual(1, results.Count); + Assert.IsFalse(results[0].IsMatch); + } + + [TestMethod] + public void AnalyzeMatch_CharacterClass_TracksMatch() + { + string expected = "value[0-9]"; + string actual = "value5"; + + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.AreEqual(1, results[0].WildcardMatches.Count); + Assert.AreEqual("value5"[5].ToString(), results[0].WildcardMatches[0].MatchedText); + } + + [TestMethod] + public void AnalyzeMatch_StarAtStart_MatchesLeadingText() + { + var results = WildcardMatchAnalyzer.AnalyzeMatch("* end", "long prefix end"); + + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.AreEqual(1, results[0].WildcardMatches.Count); + Assert.AreEqual("long prefix", results[0].WildcardMatches[0].MatchedText); + } + + [TestMethod] + public void AnalyzeMatch_MultipleConsecutiveWildcards_HandlesCorrectly() + { + string expected = "a***b"; + string actual = "aXXXb"; + + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + // Consecutive '*' wildcards collapse to a single WildcardMatch entry + Assert.AreEqual(1, results[0].WildcardMatches.Count); + Assert.AreEqual("*", results[0].WildcardMatches[0].Pattern); + Assert.AreEqual("XXX", results[0].WildcardMatches[0].MatchedText); + } + + [TestMethod] + public void AnalyzeMatch_MixedLineEndings_HandlesCorrectly() + { + // Arrange + string expected = "Line 1\r\nLine 2"; + string actual = "Line 1\nLine 2"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(2, results.Count); + Assert.IsTrue(results[0].IsMatch); + Assert.IsTrue(results[1].IsMatch); + } +} diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index 1606bd2..0fe2af8 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -236,16 +236,20 @@ public static void Expect(string expected, Action func, params string[ /// /// Options to normalize input and expected output /// A textual description of the message if the result of does not match the value + /// True when the comparison uses wildcard matching; enables the detailed wildcard diff in the failure message. + /// The escape character used to treat wildcard characters as literals. private static string Expect( string expected, Action action, Func comparisonOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, - string equivalentOperatorErrorMessage = "Values are not equal") + string equivalentOperatorErrorMessage = "Values are not equal", + bool isWildcardMatching = false, + char escapeCharacter = '\\') { (string input, string output) = Parse(expected); return Execute(input, output, action, (left, right) => comparisonOperator(left, right), - normalizeOptions, equivalentOperatorErrorMessage); + normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } /// @@ -260,21 +264,27 @@ private static string Expect( /// /// Options to normalize input and expected output /// A textual description of the message if the result of does not match the value + /// True when the comparison uses wildcard matching; enables the detailed wildcard diff in the failure message. + /// The escape character used to treat wildcard characters as literals. Passed through to the wildcard analyzer for consistent diagnostics. private static Task ExpectAsync( string expected, Func action, Func comparisonOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, - string equivalentOperatorErrorMessage = "Values are not equal") + string equivalentOperatorErrorMessage = "Values are not equal", + bool isWildcardMatching = false, + char escapeCharacter = '\\') { (string input, string output) = Parse(expected); return ExecuteAsync(input, output, action, (left, right) => comparisonOperator(left, right), - normalizeOptions, equivalentOperatorErrorMessage); + normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } private static readonly Func LikeOperator = (expected, output) => output.IsLike(expected); + private const string WildcardMismatchMessage = "The values are not like (using wildcards) each other"; + /// /// Performs a unit test on a console-based method. A "view" of /// what a user would see in their console is provided as a string, @@ -288,7 +298,8 @@ private static Task ExpectAsync( [Obsolete] public static string ExpectLike(string expected, char escapeCharacter, Action action) { - return Expect(expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter)); + return Expect(expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), + NormalizeOptions.Default, WildcardMismatchMessage, isWildcardMatching: true, escapeCharacter); } /// @@ -310,7 +321,9 @@ public static string ExpectLike(string expected, Action action, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings ? NormalizeOptions.NormalizeLineEndings : NormalizeOptions.None, - "The values are not like (using wildcards) each other"); + WildcardMismatchMessage, + isWildcardMatching: true, + escapeCharacter); } /// @@ -333,7 +346,9 @@ public static string ExpectLike(string expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings, - "The values are not like (using wildcards) each other"); + WildcardMismatchMessage, + isWildcardMatching: true, + escapeCharacter); } /// @@ -356,7 +371,9 @@ public static Task ExpectLikeAsync(string expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings, - "The values are not like (using wildcards) each other"); + WildcardMismatchMessage, + isWildcardMatching: true, + escapeCharacter); } /// @@ -397,17 +414,21 @@ private static string StripAnsiEscapeCodes(string input) /// delegate for comparing the expected from actual output. /// Options to normalize input and expected output /// A textual description of the message if the returns false + /// True when the comparison uses wildcard matching; enables the detailed wildcard diff in the failure message. + /// The escape character used to treat wildcard characters as literals. Passed through to the wildcard analyzer for consistent diagnostics. private static string Execute(string givenInput, string expectedOutput, Action action, Func areEquivalentOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, - string equivalentOperatorErrorMessage = "Values are not equal" + string equivalentOperatorErrorMessage = "Values are not equal", + bool isWildcardMatching = false, + char escapeCharacter = '\\' ) { string output = Execute(givenInput, action); - return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage); + return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } /// @@ -419,17 +440,21 @@ private static string Execute(string givenInput, /// delegate for comparing the expected from actual output. /// Options to normalize input and expected output /// A textual description of the message if the returns false + /// True when the comparison uses wildcard matching; enables the detailed wildcard diff in the failure message. + /// The escape character used to treat wildcard characters as literals. Passed through to the wildcard analyzer for consistent diagnostics. private static async Task ExecuteAsync(string givenInput, string expectedOutput, Func action, Func areEquivalentOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, - string equivalentOperatorErrorMessage = "Values are not equal" + string equivalentOperatorErrorMessage = "Values are not equal", + bool isWildcardMatching = false, + char escapeCharacter = '\\' ) { string output = await ExecuteAsync(givenInput, action); - return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage); + return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } private static string CompareOutput( @@ -437,7 +462,9 @@ private static string CompareOutput( string expectedOutput, NormalizeOptions normalizeOptions, Func areEquivalentOperator, - string equivalentOperatorErrorMessage) + string equivalentOperatorErrorMessage, + bool isWildcardMatching = false, + char escapeCharacter = '\\') { if ((normalizeOptions & NormalizeOptions.NormalizeLineEndings) != 0) { @@ -451,7 +478,7 @@ private static string CompareOutput( expectedOutput = StripAnsiEscapeCodes(expectedOutput); } - AssertExpectation(expectedOutput, output, areEquivalentOperator, equivalentOperatorErrorMessage); + AssertExpectation(expectedOutput, output, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); return output; } @@ -462,13 +489,15 @@ private static string CompareOutput( /// The actual value output. /// The operator used to compare equivalency. /// A textual description of the message if the returns false + /// True when the comparison uses wildcard matching; enables the detailed wildcard diff in the failure message. + /// The escape character used to treat wildcard characters as literals. Passed through to the wildcard analyzer for consistent diagnostics. private static void AssertExpectation(string expectedOutput, string output, Func areEquivalentOperator, - string equivalentOperatorErrorMessage = null) + string equivalentOperatorErrorMessage = null, bool isWildcardMatching = false, char escapeCharacter = '\\') { bool failTest = !areEquivalentOperator(expectedOutput, output); if (failTest) { - throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage)); + throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter)); } } @@ -555,7 +584,7 @@ public static async Task ExecuteAsync(string givenInput, Func acti } - private static string GetMessageText(string expectedOutput, string output, string equivalentOperatorErrorMessage = null) + private static string GetMessageText(string expectedOutput, string output, string equivalentOperatorErrorMessage = null, bool isWildcardMatching = false, char escapeCharacter = '\\') { string result = ""; @@ -567,7 +596,7 @@ private static string GetMessageText(string expectedOutput, string output, strin int expectedOutputLength = expectedOutput.Length; int outputLength = output.Length; - if (expectedOutputLength != outputLength) + if (expectedOutputLength != outputLength && !isWildcardMatching) { result += $"{Environment.NewLine}The expected length of {expectedOutputLength} does not match the output length of {outputLength}. "; string[] items = (new string[] { expectedOutput, output }).OrderBy(item => item.Length).ToArray(); @@ -580,18 +609,39 @@ private static string GetMessageText(string expectedOutput, string output, strin else { // Write the output that shows the difference. - for (int counter = 0; counter < Math.Min(expectedOutput.Length, output.Length); counter++) + // Skip character-by-character comparison for wildcard matching as wildcards intentionally differ from literal text. + if (!isWildcardMatching) { - if (expectedOutput[counter] != output[counter]) // TODO: The message is invalid when using wild cards. + for (int counter = 0; counter < Math.Min(expectedOutput.Length, output.Length); counter++) { - result += Environment.NewLine - + $"Character {counter} did not match: " - + $"'{CSharpStringEncode(expectedOutput[counter])}' != '{CSharpStringEncode(output[counter])})'"; - - break; + if (expectedOutput[counter] != output[counter]) + { + result += Environment.NewLine + + $"Character {counter} did not match: " + + $"'{CSharpStringEncode(expectedOutput[counter])}' != '{CSharpStringEncode(output[counter])}'"; + + break; + } } } } + + // If wildcard matching is being used, add detailed line-by-line analysis + if (isWildcardMatching) + { + try + { + var matchResults = WildcardMatchAnalyzer.AnalyzeMatch(expectedOutput, output, escapeCharacter); + result += WildcardMatchAnalyzer.GenerateDetailedDiff(matchResults); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Pattern analysis failed — inform user but don't crash the test runner + result += Environment.NewLine + + $"⚠️ Note: Could not generate detailed wildcard analysis: {ex.Message}"; + } + } + return result; } @@ -718,7 +768,7 @@ public static Process ExecuteProcess(string expected, string fileName, string ar process.WaitForExit(); standardOutput = process.StandardOutput.ReadToEnd(); standardError = process.StandardError.ReadToEnd(); - AssertExpectation(expected, standardOutput, (left, right) => LikeOperator(left, right), "The values are not like (using wildcards) each other"); + AssertExpectation(expected, standardOutput, LikeOperator, WildcardMismatchMessage, isWildcardMatching: true); return process; } diff --git a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs new file mode 100644 index 0000000..7d9c3cb --- /dev/null +++ b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs @@ -0,0 +1,400 @@ +using System.Text; + +namespace IntelliTect.TestTools.Console; + +/// +/// Analyzes wildcard pattern matching to provide detailed diff information. +/// +internal static class WildcardMatchAnalyzer +{ + /// + /// Represents the result of matching a single line. + /// + internal class LineMatchResult + { + public bool IsMatch { get; set; } + + /// + /// The expected line pattern. Null if this line exists in actual output but not in expected. + /// + public string ExpectedLine { get; set; } + + /// + /// The actual line text. Null if this line exists in expected output but not in actual. + /// + public string ActualLine { get; set; } + + public List WildcardMatches { get; set; } = new List(); + public string Status => IsMatch ? "✅" : "❌"; + } + + /// + /// Represents what a single wildcard matched. + /// + internal class WildcardMatch + { + /// + /// The wildcard pattern (e.g., "*", "?", or "[...]"). + /// + public string Pattern { get; set; } = string.Empty; + + /// + /// The text that was matched by this wildcard. + /// + public string MatchedText { get; set; } = string.Empty; + } + + /// + /// Analyzes wildcard pattern matching line by line and returns detailed results. + /// + /// The expected output pattern, which may contain wildcard characters. + /// The actual console output to compare against the pattern. + /// The escape character used to treat wildcard characters as literals. Defaults to '\'. + internal static List AnalyzeMatch(string expectedPattern, string actualText, char escapeCharacter = '\\') + { + var results = new List(); + + // Split into lines + string[] expectedLines = SplitIntoLines(expectedPattern); + string[] actualLines = SplitIntoLines(actualText); + + int maxLines = Math.Max(expectedLines.Length, actualLines.Length); + + for (int i = 0; i < maxLines; i++) + { + string expectedLine = i < expectedLines.Length ? expectedLines[i] : null; + string actualLine = i < actualLines.Length ? actualLines[i] : null; + + var lineResult = new LineMatchResult + { + ExpectedLine = expectedLine, + ActualLine = actualLine + }; + + if (expectedLine == null) + { + // Extra line in actual output + lineResult.IsMatch = false; + } + else if (actualLine == null) + { + // Missing line in actual output + lineResult.IsMatch = false; + } + else + { + // Try to match the line and capture what wildcards matched + lineResult.IsMatch = MatchLineWithWildcards(expectedLine, actualLine, lineResult.WildcardMatches, escapeCharacter); + } + + results.Add(lineResult); + } + + return results; + } + + /// + /// Generates a detailed diff message from the match results. + /// + internal static string GenerateDetailedDiff(List matchResults) + { + var sb = new StringBuilder(); + sb.AppendLine(); + sb.AppendLine("Line-by-line comparison:"); + sb.AppendLine("========================"); + + for (int i = 0; i < matchResults.Count; i++) + { + var result = matchResults[i]; + sb.AppendLine(); + sb.AppendLine($"Line {i + 1}: {result.Status}"); + + if (result.ExpectedLine == null) + { + sb.AppendLine($" ⚠️ Unexpected extra line in actual output:"); + sb.AppendLine($" Actual: {EscapeForDisplay(result.ActualLine)}"); + } + else if (result.ActualLine == null) + { + sb.AppendLine($" ⚠️ Missing line in actual output:"); + sb.AppendLine($" Expected: {EscapeForDisplay(result.ExpectedLine)}"); + } + else + { + sb.AppendLine($" Expected: {EscapeForDisplay(result.ExpectedLine)}"); + sb.AppendLine($" Actual: {EscapeForDisplay(result.ActualLine)}"); + + if (result.IsMatch && result.WildcardMatches.Count > 0) + { + sb.AppendLine($" Wildcard matches:"); + foreach (var match in result.WildcardMatches) + { + sb.AppendLine($" '{match.Pattern}' matched: {EscapeForDisplay(match.MatchedText)}"); + } + } + else if (!result.IsMatch) + { + // Only show positional mismatch info for literal patterns; wildcard patterns + // produce misleading output (e.g., "expected '*' but got 'x'"). + if (!ContainsWildcard(result.ExpectedLine)) + { + string mismatchInfo = FindMismatchPosition(result.ExpectedLine, result.ActualLine); + if (!string.IsNullOrEmpty(mismatchInfo)) + { + sb.AppendLine($" {mismatchInfo}"); + } + } + } + } + } + + return sb.ToString(); + } + + /// + /// Tries to match a line with wildcards and captures what each wildcard matched. + /// This is a simplified implementation that tracks * and ? wildcards. + /// + private static bool MatchLineWithWildcards(string pattern, string text, List wildcardMatches, char escapeCharacter) + { + try + { + var wildcardPattern = new WildcardPattern(pattern, escapeCharacter); + bool isMatch = wildcardPattern.IsMatch(text); + + if (isMatch) + { + // Extract what each wildcard matched + ExtractWildcardMatches(pattern, text, wildcardMatches); + } + + return isMatch; + } + catch (ArgumentException) + { + // Invalid pattern - treat as no match + return false; + } + } + + /// + /// Extracts what each wildcard in the pattern matched in the text. + /// + /// Algorithm: + /// 1. For '*': Greedily matches text until the next literal prefix in the remaining pattern + /// 2. For '?': Matches exactly one character + /// 3. For '[...]': Matches one character from the set + /// + /// Note: This is a simplified extraction that uses a greedy approach and may not + /// perfectly represent the actual backtracking behavior of WildcardPattern.IsMatch(). + /// It's designed to give users helpful debugging information rather than to be a + /// perfect replica of the matching engine. In complex patterns with multiple wildcards, + /// the actual match may differ from what this heuristic reports. + /// + /// Known limitation: when '?' follows '*', the greedy '*' finds no literal anchor and + /// matches the empty string at the current position; '?' is then recorded as its own + /// separate entry. The reported split may differ from the actual backtracking match. + /// + private static void ExtractWildcardMatches(string pattern, string text, List wildcardMatches) + { + int patternPos = 0; + int textPos = 0; + + while (patternPos < pattern.Length && textPos <= text.Length) + { + char patternChar = pattern[patternPos]; + + if (patternChar == '*') + { + // Skip consecutive '*' characters (equivalent wildcards) + int nextPatternPos = patternPos + 1; + while (nextPatternPos < pattern.Length && pattern[nextPatternPos] == '*') + { + nextPatternPos++; + } + + // Find where the next literal prefix of the remaining pattern appears in the text. + // If '*' is at the end of the pattern, skip the search entirely. + string remainingPattern = nextPatternPos < pattern.Length ? pattern.Substring(nextPatternPos) : null; + int nextLiteralIndex = remainingPattern != null ? FindNextLiteralMatch(text, textPos, remainingPattern) : -1; + + if (nextPatternPos >= pattern.Length || nextLiteralIndex == -1) + { + // '*' at the end of pattern, or remaining pattern has no literal anchor — + // consume everything remaining in the text. + wildcardMatches.Add(new WildcardMatch + { + Pattern = "*", + MatchedText = text.Substring(textPos) + }); + return; + } + + // '*' matched everything from textPos to nextLiteralIndex + wildcardMatches.Add(new WildcardMatch + { + Pattern = "*", + MatchedText = text.Substring(textPos, nextLiteralIndex - textPos) + }); + + textPos = nextLiteralIndex; + patternPos = nextPatternPos; + } + else if (patternChar == '?') + { + // '?' matches exactly one character + if (textPos < text.Length) + { + wildcardMatches.Add(new WildcardMatch + { + Pattern = "?", + MatchedText = text[textPos].ToString() + }); + textPos++; + } + patternPos++; + } + else if (patternChar == '[') + { + // Character class — skip to the closing ']' + int closingBracket = pattern.IndexOf(']', patternPos); + if (closingBracket > patternPos && textPos < text.Length) + { + string charClass = pattern.Substring(patternPos, closingBracket - patternPos + 1); + wildcardMatches.Add(new WildcardMatch + { + Pattern = charClass, + MatchedText = text[textPos].ToString() + }); + textPos++; + patternPos = closingBracket + 1; + } + else + { + patternPos++; + } + } + else + { + // Literal character — must match exactly + if (textPos < text.Length && text[textPos] == patternChar) + { + textPos++; + } + patternPos++; + } + } + } + + /// + /// Finds the earliest position in (at or after ) + /// where the leading literal prefix of appears as a substring. + /// This prevents false matches — e.g., pattern "and end" won't match the 'a' in "another". + /// Returns -1 if no such position exists. + /// + private static int FindNextLiteralMatch(string text, int startPos, string pattern) + { + // Build the leading literal prefix (stop at the first wildcard) + var literalPrefix = new StringBuilder(); + for (int i = 0; i < pattern.Length; i++) + { + char c = pattern[i]; + if (c == '*' || c == '?' || c == '[') + break; + literalPrefix.Append(c); + } + + if (literalPrefix.Length == 0) + return startPos; // No literal anchor — '*' can match at current position + + string prefix = literalPrefix.ToString(); + int index = text.IndexOf(prefix, startPos, StringComparison.Ordinal); + return index; // -1 if not found + } + + /// + /// Finds the position where expected and actual first differ (literal comparison). + /// Only called for lines that do not contain wildcards — safe for positional comparison. + /// + private static string FindMismatchPosition(string expected, string actual) + { + int minLength = Math.Min(expected.Length, actual.Length); + + for (int i = 0; i < minLength; i++) + { + if (expected[i] != actual[i]) + { + return $"Mismatch at position {i}: expected '{EscapeChar(expected[i])}' but got '{EscapeChar(actual[i])}'"; + } + } + + if (expected.Length != actual.Length) + { + return $"Length mismatch: expected {expected.Length} characters but got {actual.Length}"; + } + + return string.Empty; + } + + /// + /// Splits text into lines, normalizing line endings. + /// A single trailing newline is stripped before splitting so that + /// Console.WriteLine("X") produces ["X"] not ["X", ""]. + /// + private static string[] SplitIntoLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return Array.Empty(); + } + + // Strip single trailing newline written by Console.WriteLine + if (text.EndsWith("\r\n", StringComparison.Ordinal)) + text = text.Substring(0, text.Length - 2); + else if (text.EndsWith("\n", StringComparison.Ordinal) || text.EndsWith("\r", StringComparison.Ordinal)) + text = text.Substring(0, text.Length - 1); + + if (text.Length == 0) + return Array.Empty(); + + return text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + } + + /// + /// Returns true if the pattern contains any wildcard characters (*, ?, or [). + /// + private static bool ContainsWildcard(string pattern) + { + if (pattern == null) return false; + // Note: string.Contains(char) is not available on netstandard2.0. + return pattern.IndexOf('*') >= 0 || pattern.IndexOf('?') >= 0 || pattern.IndexOf('[') >= 0; + } + + /// + /// Escapes a string for display, showing special characters. + /// + private static string EscapeForDisplay(string text) + { + if (text == null) return ""; + if (text == string.Empty) return ""; + + var sb = new StringBuilder(text.Length); + foreach (char c in text) + sb.Append(EscapeChar(c)); + return sb.ToString(); + } + + /// + /// Escapes a single character for display. + /// + private static string EscapeChar(char c) + { + return c switch + { + '\r' => "\\r", + '\n' => "\\n", + '\t' => "\\t", + _ => c.ToString() + }; + } +}