From 253aac99b5529399a8a87ea15a1a0fa689b24eb2 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 01:19:20 -0700 Subject: [PATCH 1/8] Add detailed wildcard match diff to ConsoleAssert failure output - Add WildcardMatchAnalyzer to provide line-by-line wildcard diff - Enhance GetMessageText to include detailed wildcard analysis when wildcard matching fails (detected via error message containing 'wildcard') - Skip unhelpful char-by-char diff for wildcard failures - Keep ConsoleAssertException (fix regression from original PR) - Add EnhancedErrorMessageTests and WildcardMatchAnalyzerTests Fixes #69 --- .../EnhancedErrorMessageTests.cs | 110 +++++ .../WildcardMatchAnalyzerTests.cs | 190 +++++++++ .../ConsoleAssert.cs | 47 ++- .../WildcardMatchAnalyzer.cs | 382 ++++++++++++++++++ 4 files changed, 720 insertions(+), 9 deletions(-) create mode 100644 IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs create mode 100644 IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs create mode 100644 IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs diff --git a/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs new file mode 100644 index 0000000..9385f22 --- /dev/null +++ b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs @@ -0,0 +1,110 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace IntelliTect.TestTools.Console.Tests; + +[TestClass] +public class EnhancedErrorMessageTests +{ + [TestMethod] + public void ExpectLike_WildcardMismatch_ShowsDetailedDiff() + { + // Arrange - pattern with wildcards that will NOT match + string expected = "PING *(::1) 56 data bytes\n64 bytes from *"; + + // Act & Assert - ConsoleAssert throws ConsoleAssertException on mismatch + ConsoleAssertException exception = Assert.ThrowsExactly(() => + { + ConsoleAssert.ExpectLike(expected, () => + { + System.Console.Write("PING localhost(::1) WRONG data bytes\n64 bytes from localhost"); + }); + }); + + // Assert - Check that the error message contains the enhanced output + string errorMessage = exception.Message; + + // Should contain the line-by-line comparison header + StringAssert.Contains(errorMessage, "Line-by-line comparison"); + + // Should contain status indicators + StringAssert.Contains(errorMessage, "❌"); // Lines don't match + } + + [TestMethod] + public void ExpectLike_ExtraLines_IdentifiedAsSuch() + { + // Arrange - pattern expecting one line, but getting two + string expected = "Line 1"; + + // Act & Assert + ConsoleAssertException exception = Assert.ThrowsExactly(() => + { + ConsoleAssert.ExpectLike(expected, () => + { + System.Console.WriteLine("Line 1"); + System.Console.WriteLine("Line 2"); + }); + }); + + // Assert + string errorMessage = exception.Message; + // With line-by-line comparison, we should see the extra line marked + StringAssert.Contains(errorMessage, "Line 2: ❌"); + StringAssert.Contains(errorMessage, "Unexpected extra line"); + } + + [TestMethod] + public void ExpectLike_MissingLines_IdentifiedAsSuch() + { + // Arrange - pattern with wildcards expecting more lines + string expected = "Line 1\nLine *"; + + // Act & Assert + ConsoleAssertException exception = Assert.ThrowsExactly(() => + { + ConsoleAssert.ExpectLike(expected, () => + { + System.Console.WriteLine("Line 1"); + }); + }); + + // Assert + string errorMessage = exception.Message; + StringAssert.Contains(errorMessage, "Missing line"); + } + + [TestMethod] + public void ExecuteProcess_WildcardMismatch_ShowsDetailedDiff() + { + // This test demonstrates the improved error output for ExecuteProcess + // when wildcard matching fails. + + string expected = "This * should match"; + + ConsoleAssertException exception = null; + try + { + ConsoleAssert.ExecuteProcess( + expected, + "echo", + "Output that does not match", + out string _, + out _); + } + catch (ConsoleAssertException ex) + { + exception = ex; + } + + // Assert + Assert.IsNotNull(exception); + string errorMessage = exception.Message; + + // Should contain wildcard matching error message + StringAssert.Contains(errorMessage, "wildcard"); + + // Should contain the line-by-line comparison + StringAssert.Contains(errorMessage, "Line-by-line comparison"); + } +} diff --git a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs new file mode 100644 index 0000000..e8fc1c2 --- /dev/null +++ b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs @@ -0,0 +1,190 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace IntelliTect.TestTools.Console.Tests; + +[TestClass] +public class WildcardMatchAnalyzerTests +{ + [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() + { + // Arrange + string expected = "Hello * world\nLine *"; + string actual = "Hello beautiful world\nLine 2"; + + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Act + string diff = WildcardMatchAnalyzer.GenerateDetailedDiff(results); + + // Assert + Assert.IsFalse(string.IsNullOrEmpty(diff)); + StringAssert.Contains(diff, "Line-by-line comparison"); + StringAssert.Contains(diff, "✅"); // Should contain success markers + StringAssert.Contains(diff, "Wildcard matches"); + } + + [TestMethod] + public void GenerateDetailedDiff_WithMismatch_ShowsFailure() + { + // Arrange + string expected = "Expected text"; + string actual = "Actual text"; + + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Act + string diff = WildcardMatchAnalyzer.GenerateDetailedDiff(results); + + // Assert + Assert.IsFalse(string.IsNullOrEmpty(diff)); + StringAssert.Contains(diff, "❌"); // Should contain failure marker + } + + [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_MultipleConsecutiveWildcards_HandlesCorrectly() + { + // Arrange + string expected = "a***b"; + string actual = "aXXXb"; + + // Act + var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + + // Assert + Assert.AreEqual(1, results.Count); + Assert.IsTrue(results[0].IsMatch); + } + + [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..b2f0073 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -468,7 +468,9 @@ private static void AssertExpectation(string expectedOutput, string output, Func bool failTest = !areEquivalentOperator(expectedOutput, output); if (failTest) { - throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage)); + // Detect wildcard matching by checking the error message for the wildcard-specific phrase. + bool isWildcardMatching = equivalentOperatorErrorMessage?.Contains("wildcard", StringComparison.OrdinalIgnoreCase) == true; + throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage, isWildcardMatching)); } } @@ -555,7 +557,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) { string result = ""; @@ -580,18 +582,45 @@ 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]) // TODO: The message is invalid when using wild cards. + { + 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); + result += WildcardMatchAnalyzer.GenerateDetailedDiff(matchResults); + } + catch (ArgumentException ex) + { + // Pattern analysis failed - inform user but don't crash + result += Environment.NewLine + + $"⚠️ Note: Could not generate detailed wildcard analysis: {ex.Message}"; + } + catch (InvalidOperationException ex) + { + // Analysis encountered an unexpected state - inform user but don't crash + result += Environment.NewLine + + $"⚠️ Note: Wildcard analysis encountered an error: {ex.Message}"; + } + } + return result; } diff --git a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs new file mode 100644 index 0000000..b0c471d --- /dev/null +++ b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs @@ -0,0 +1,382 @@ +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; + + public int Position { get; set; } + } + + /// + /// Analyzes wildcard pattern matching line by line and returns detailed results. + /// + public static List AnalyzeMatch(string expectedPattern, string actualText) + { + 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); + } + + results.Add(lineResult); + } + + return results; + } + + /// + /// Generates a detailed diff message from the match results. + /// + public 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) + { + // Try to show where the mismatch occurred + 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) + { + try + { + var wildcardPattern = new WildcardPattern(pattern); + 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 character in 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. + /// + private static void ExtractWildcardMatches(string pattern, string text, List wildcardMatches) + { + int patternPos = 0; + int textPos = 0; + int wildcardIndex = 0; + + while (patternPos < pattern.Length && textPos <= text.Length) + { + char patternChar = pattern[patternPos]; + + if (patternChar == '*') + { + // Find the next non-wildcard character in the pattern + int nextPatternPos = patternPos + 1; + while (nextPatternPos < pattern.Length && (pattern[nextPatternPos] == '*' || pattern[nextPatternPos] == '?')) + { + nextPatternPos++; + } + + if (nextPatternPos >= pattern.Length) + { + // * at the end matches everything remaining + wildcardMatches.Add(new WildcardMatch + { + Pattern = "*", + MatchedText = text.Substring(textPos), + Position = wildcardIndex++ + }); + return; + } + + // Find where the next literal character appears in the text + string remainingPattern = pattern.Substring(nextPatternPos); + int nextLiteralIndex = FindNextLiteralMatch(text, textPos, remainingPattern); + + if (nextLiteralIndex == -1) + { + // Could not find a match for the remaining pattern + wildcardMatches.Add(new WildcardMatch + { + Pattern = "*", + MatchedText = text.Substring(textPos), + Position = wildcardIndex++ + }); + return; + } + + // The * matched everything from textPos to nextLiteralIndex + wildcardMatches.Add(new WildcardMatch + { + Pattern = "*", + MatchedText = text.Substring(textPos, nextLiteralIndex - textPos), + Position = wildcardIndex++ + }); + + textPos = nextLiteralIndex; + patternPos++; + } + else if (patternChar == '?') + { + // ? matches exactly one character + if (textPos < text.Length) + { + wildcardMatches.Add(new WildcardMatch + { + Pattern = "?", + MatchedText = text[textPos].ToString(), + Position = wildcardIndex++ + }); + 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(), + Position = wildcardIndex++ + }); + textPos++; + patternPos = closingBracket + 1; + } + else + { + patternPos++; + } + } + else + { + // Literal character - must match exactly + if (textPos < text.Length && text[textPos] == patternChar) + { + textPos++; + } + patternPos++; + } + } + } + + /// + /// Finds the next position where the pattern starts matching in the text. + /// + private static int FindNextLiteralMatch(string text, int startPos, string pattern) + { + // Simple heuristic: find the first non-wildcard character in the pattern + // and search for it in the text + for (int i = 0; i < pattern.Length; i++) + { + char c = pattern[i]; + if (c != '*' && c != '?' && c != '[') + { + // Found a literal character, search for it + int index = text.IndexOf(c, startPos); + if (index >= 0) + { + return index; + } + return -1; + } + } + return -1; // Pattern has no literals + } + + /// + /// Finds the position where pattern and text first differ (for non-wildcard mismatches). + /// + 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] && expected[i] != '*' && expected[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. + /// + private static string[] SplitIntoLines(string text) + { + if (string.IsNullOrEmpty(text)) + { + return Array.Empty(); + } + + return text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); + } + + /// + /// Escapes a string for display, showing special characters. + /// + private static string EscapeForDisplay(string text) + { + if (text == null) return ""; + if (text == string.Empty) return ""; + + return text + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + } + + /// + /// Escapes a single character for display. + /// + private static string EscapeChar(char c) + { + return c switch + { + '\r' => "\\r", + '\n' => "\\n", + '\t' => "\\t", + _ => c.ToString() + }; + } +} From d9b4dc37eb90135d42db2113a51c59028622e128 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 01:29:11 -0700 Subject: [PATCH 2/8] Fix review findings: netstandard2.0 compat, wildcard extraction, trailing newline, dead code - ConsoleAssert.cs: use IndexOf(...) >= 0 instead of Contains(string, StringComparison) for netstandard2.0 compatibility; broaden catch to Exception; remove stale TODO comment - WildcardMatchAnalyzer: remove dead Position field from WildcardMatch; make AnalyzeMatch and GenerateDetailedDiff internal (not public); fix ExtractWildcardMatches to not swallow '?' after '*'; fix FindNextLiteralMatch to match full literal prefix (not just first char); fix FindMismatchPosition to remove wildcard skip logic (it's only called for non-wildcards); fix SplitIntoLines to strip single trailing newline from Console.WriteLine output - Tests: remove platform-fragile echo-based ExecuteProcess test; add happy-path test; add '?' failure path, '[...]' character class, '*' at start, and consecutive wildcard tests with proper assertions --- .../EnhancedErrorMessageTests.cs | 34 +------ .../WildcardMatchAnalyzerTests.cs | 42 ++++++++- .../ConsoleAssert.cs | 15 +-- .../WildcardMatchAnalyzer.cs | 93 +++++++++++-------- 4 files changed, 101 insertions(+), 83 deletions(-) diff --git a/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs index 9385f22..9452474 100644 --- a/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs +++ b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs @@ -75,36 +75,12 @@ public void ExpectLike_MissingLines_IdentifiedAsSuch() } [TestMethod] - public void ExecuteProcess_WildcardMismatch_ShowsDetailedDiff() + public void ExpectLike_SuccessfulWildcardMatch_DoesNotThrow() { - // This test demonstrates the improved error output for ExecuteProcess - // when wildcard matching fails. - - string expected = "This * should match"; - - ConsoleAssertException exception = null; - try - { - ConsoleAssert.ExecuteProcess( - expected, - "echo", - "Output that does not match", - out string _, - out _); - } - catch (ConsoleAssertException ex) + // Verify the feature doesn't break the happy path + ConsoleAssert.ExpectLike("Hello * world", () => { - exception = ex; - } - - // Assert - Assert.IsNotNull(exception); - string errorMessage = exception.Message; - - // Should contain wildcard matching error message - StringAssert.Contains(errorMessage, "wildcard"); - - // Should contain the line-by-line comparison - StringAssert.Contains(errorMessage, "Line-by-line comparison"); + System.Console.Write("Hello beautiful world"); + }); } } diff --git a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs index e8fc1c2..5f6e3a2 100644 --- a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs +++ b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs @@ -157,19 +157,55 @@ public void AnalyzeMatch_QuestionMarkWildcard_TracksMatch() 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() { - // Arrange string expected = "a***b"; string actual = "aXXXb"; - // Act var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); - // Assert 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] diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index b2f0073..d8b8d96 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -469,7 +469,8 @@ private static void AssertExpectation(string expectedOutput, string output, Func if (failTest) { // Detect wildcard matching by checking the error message for the wildcard-specific phrase. - bool isWildcardMatching = equivalentOperatorErrorMessage?.Contains("wildcard", StringComparison.OrdinalIgnoreCase) == true; + // Note: string.Contains(string, StringComparison) is not available on netstandard2.0. + bool isWildcardMatching = equivalentOperatorErrorMessage?.IndexOf("wildcard", StringComparison.OrdinalIgnoreCase) >= 0; throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage, isWildcardMatching)); } } @@ -587,7 +588,7 @@ private static string GetMessageText(string expectedOutput, string output, strin { for (int counter = 0; counter < Math.Min(expectedOutput.Length, output.Length); counter++) { - if (expectedOutput[counter] != output[counter]) // TODO: The message is invalid when using wild cards. + if (expectedOutput[counter] != output[counter]) { result += Environment.NewLine + $"Character {counter} did not match: " @@ -607,18 +608,12 @@ private static string GetMessageText(string expectedOutput, string output, strin var matchResults = WildcardMatchAnalyzer.AnalyzeMatch(expectedOutput, output); result += WildcardMatchAnalyzer.GenerateDetailedDiff(matchResults); } - catch (ArgumentException ex) + catch (Exception ex) { - // Pattern analysis failed - inform user but don't crash + // Pattern analysis failed — inform user but don't crash the test runner result += Environment.NewLine + $"⚠️ Note: Could not generate detailed wildcard analysis: {ex.Message}"; } - catch (InvalidOperationException ex) - { - // Analysis encountered an unexpected state - inform user but don't crash - result += Environment.NewLine + - $"⚠️ Note: Wildcard analysis encountered an error: {ex.Message}"; - } } return result; diff --git a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs index b0c471d..c5e5cde 100644 --- a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs +++ b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs @@ -42,14 +42,12 @@ internal class WildcardMatch /// The text that was matched by this wildcard. /// public string MatchedText { get; set; } = string.Empty; - - public int Position { get; set; } } /// /// Analyzes wildcard pattern matching line by line and returns detailed results. /// - public static List AnalyzeMatch(string expectedPattern, string actualText) + internal static List AnalyzeMatch(string expectedPattern, string actualText) { var results = new List(); @@ -95,7 +93,7 @@ public static List AnalyzeMatch(string expectedPattern, string /// /// Generates a detailed diff message from the match results. /// - public static string GenerateDetailedDiff(List matchResults) + internal static string GenerateDetailedDiff(List matchResults) { var sb = new StringBuilder(); sb.AppendLine(); @@ -176,7 +174,7 @@ private static bool MatchLineWithWildcards(string pattern, string text, List private static void ExtractWildcardMatches(string pattern, string text, List wildcardMatches) { int patternPos = 0; int textPos = 0; - int wildcardIndex = 0; while (patternPos < pattern.Length && textPos <= text.Length) { @@ -198,62 +199,58 @@ private static void ExtractWildcardMatches(string pattern, string text, List= pattern.Length) { - // * at the end matches everything remaining + // '*' at the end matches everything remaining wildcardMatches.Add(new WildcardMatch { Pattern = "*", - MatchedText = text.Substring(textPos), - Position = wildcardIndex++ + MatchedText = text.Substring(textPos) }); return; } - // Find where the next literal character appears in the text + // Find where the next literal prefix of the remaining pattern appears in the text string remainingPattern = pattern.Substring(nextPatternPos); int nextLiteralIndex = FindNextLiteralMatch(text, textPos, remainingPattern); if (nextLiteralIndex == -1) { - // Could not find a match for the remaining pattern + // Could not find a match for the remaining pattern — '*' consumed the rest wildcardMatches.Add(new WildcardMatch { Pattern = "*", - MatchedText = text.Substring(textPos), - Position = wildcardIndex++ + MatchedText = text.Substring(textPos) }); return; } - // The * matched everything from textPos to nextLiteralIndex + // '*' matched everything from textPos to nextLiteralIndex wildcardMatches.Add(new WildcardMatch { Pattern = "*", - MatchedText = text.Substring(textPos, nextLiteralIndex - textPos), - Position = wildcardIndex++ + MatchedText = text.Substring(textPos, nextLiteralIndex - textPos) }); textPos = nextLiteralIndex; - patternPos++; + patternPos = nextPatternPos; } else if (patternChar == '?') { - // ? matches exactly one character + // '?' matches exactly one character if (textPos < text.Length) { wildcardMatches.Add(new WildcardMatch { Pattern = "?", - MatchedText = text[textPos].ToString(), - Position = wildcardIndex++ + MatchedText = text[textPos].ToString() }); textPos++; } @@ -261,7 +258,7 @@ private static void ExtractWildcardMatches(string pattern, string text, List patternPos && textPos < text.Length) { @@ -269,8 +266,7 @@ private static void ExtractWildcardMatches(string pattern, string text, List private static int FindNextLiteralMatch(string text, int startPos, string pattern) + /// + /// 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) { - // Simple heuristic: find the first non-wildcard character in the pattern - // and search for it in the text + // 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 != '[') - { - // Found a literal character, search for it - int index = text.IndexOf(c, startPos); - if (index >= 0) - { - return index; - } - return -1; - } + if (c == '*' || c == '?' || c == '[') + break; + literalPrefix.Append(c); } - return -1; // Pattern has no literals + + 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 pattern and text first differ (for non-wildcard mismatches). + /// 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) { @@ -325,7 +328,7 @@ private static string FindMismatchPosition(string expected, string actual) for (int i = 0; i < minLength; i++) { - if (expected[i] != actual[i] && expected[i] != '*' && expected[i] != '?') + if (expected[i] != actual[i]) { return $"Mismatch at position {i}: expected '{EscapeChar(expected[i])}' but got '{EscapeChar(actual[i])}'"; } @@ -341,6 +344,8 @@ private static string FindMismatchPosition(string expected, string actual) /// /// 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) { @@ -349,6 +354,12 @@ private static string[] SplitIntoLines(string 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); + return text.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None); } From 04d671dff2f821e838336ee8fbfeb748361faee5 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 01:39:03 -0700 Subject: [PATCH 3/8] Fix round-2 review findings: compile error, wildcard guard, explicit isWildcardMatching param - WildcardMatchAnalyzer: remove stale duplicate FindNextLiteralMatch signature (build-blocker); guard FindMismatchPosition call to only run on non-wildcard lines (prevents misleading output); add ContainsWildcard helper; fix SplitIntoLines empty-string edge case after stripping newline - ConsoleAssert: replace fragile error-message string-sniffing with explicit isWildcardMatching bool parameter threaded through Expect/ExpectAsync/Execute/ExecuteAsync/CompareOutput/AssertExpectation; all ExpectLike* and ExecuteProcess callers now explicitly pass isWildcardMatching: true --- .../ConsoleAssert.cs | 45 +++++++++++-------- .../WildcardMatchAnalyzer.cs | 29 ++++++++---- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index d8b8d96..e7a98bf 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -239,13 +239,14 @@ public static void Expect(string expected, Action func, params string[ 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) { (string input, string output) = Parse(expected); return Execute(input, output, action, (left, right) => comparisonOperator(left, right), - normalizeOptions, equivalentOperatorErrorMessage); + normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching); } /// @@ -263,13 +264,14 @@ private static string Expect( 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) { (string input, string output) = Parse(expected); return ExecuteAsync(input, output, action, (left, right) => comparisonOperator(left, right), - normalizeOptions, equivalentOperatorErrorMessage); + normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching); } private static readonly Func LikeOperator = @@ -288,7 +290,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, "The values are not like (using wildcards) each other", isWildcardMatching: true); } /// @@ -310,7 +313,8 @@ 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"); + "The values are not like (using wildcards) each other", + isWildcardMatching: true); } /// @@ -333,7 +337,8 @@ public static string ExpectLike(string expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings, - "The values are not like (using wildcards) each other"); + "The values are not like (using wildcards) each other", + isWildcardMatching: true); } /// @@ -356,7 +361,8 @@ public static Task ExpectLikeAsync(string expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings, - "The values are not like (using wildcards) each other"); + "The values are not like (using wildcards) each other", + isWildcardMatching: true); } /// @@ -402,12 +408,13 @@ private static string Execute(string givenInput, Action action, Func areEquivalentOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, - string equivalentOperatorErrorMessage = "Values are not equal" + string equivalentOperatorErrorMessage = "Values are not equal", + bool isWildcardMatching = false ) { string output = Execute(givenInput, action); - return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage); + return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching); } /// @@ -424,12 +431,13 @@ private static async Task ExecuteAsync(string givenInput, Func action, Func areEquivalentOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, - string equivalentOperatorErrorMessage = "Values are not equal" + string equivalentOperatorErrorMessage = "Values are not equal", + bool isWildcardMatching = false ) { string output = await ExecuteAsync(givenInput, action); - return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage); + return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching); } private static string CompareOutput( @@ -437,7 +445,8 @@ private static string CompareOutput( string expectedOutput, NormalizeOptions normalizeOptions, Func areEquivalentOperator, - string equivalentOperatorErrorMessage) + string equivalentOperatorErrorMessage, + bool isWildcardMatching = false) { if ((normalizeOptions & NormalizeOptions.NormalizeLineEndings) != 0) { @@ -451,7 +460,7 @@ private static string CompareOutput( expectedOutput = StripAnsiEscapeCodes(expectedOutput); } - AssertExpectation(expectedOutput, output, areEquivalentOperator, equivalentOperatorErrorMessage); + AssertExpectation(expectedOutput, output, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching); return output; } @@ -462,15 +471,13 @@ 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. private static void AssertExpectation(string expectedOutput, string output, Func areEquivalentOperator, - string equivalentOperatorErrorMessage = null) + string equivalentOperatorErrorMessage = null, bool isWildcardMatching = false) { bool failTest = !areEquivalentOperator(expectedOutput, output); if (failTest) { - // Detect wildcard matching by checking the error message for the wildcard-specific phrase. - // Note: string.Contains(string, StringComparison) is not available on netstandard2.0. - bool isWildcardMatching = equivalentOperatorErrorMessage?.IndexOf("wildcard", StringComparison.OrdinalIgnoreCase) >= 0; throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage, isWildcardMatching)); } } @@ -742,7 +749,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, (left, right) => LikeOperator(left, right), "The values are not like (using wildcards) each other", isWildcardMatching: true); return process; } diff --git a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs index c5e5cde..29be5d8 100644 --- a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs +++ b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs @@ -131,11 +131,15 @@ internal static string GenerateDetailedDiff(List matchResults) } else if (!result.IsMatch) { - // Try to show where the mismatch occurred - string mismatchInfo = FindMismatchPosition(result.ExpectedLine, result.ActualLine); - if (!string.IsNullOrEmpty(mismatchInfo)) + // Only show positional mismatch info for literal patterns; wildcard patterns + // produce misleading output (e.g., "expected '*' but got 'x'"). + if (!ContainsWildcard(result.ExpectedLine)) { - sb.AppendLine($" {mismatchInfo}"); + string mismatchInfo = FindMismatchPosition(result.ExpectedLine, result.ActualLine); + if (!string.IsNullOrEmpty(mismatchInfo)) + { + sb.AppendLine($" {mismatchInfo}"); + } } } } @@ -288,10 +292,6 @@ private static void ExtractWildcardMatches(string pattern, string text, List - /// Finds the next position where the pattern starts matching in the text. - /// - private static int FindNextLiteralMatch(string text, int startPos, string pattern) /// /// Finds the earliest position in (at or after ) /// where the leading literal prefix of appears as a substring. @@ -360,9 +360,22 @@ private static string[] SplitIntoLines(string text) 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. /// From d71e786083c241dd850996700654d12777e817fc Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 01:47:38 -0700 Subject: [PATCH 4/8] Add missing XML doc param tags to prevent CS1573 build errors TreatWarningsAsErrors=true + GenerateDocumentationFile=true means CS1573 (missing param tag) is a build error. Add isWildcardMatching param tags to: Expect, ExpectAsync, Execute, ExecuteAsync. --- IntelliTect.TestTools.Console/ConsoleAssert.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index e7a98bf..1e09778 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -236,6 +236,7 @@ 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. private static string Expect( string expected, Action action, Func comparisonOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, @@ -261,6 +262,7 @@ 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. private static Task ExpectAsync( string expected, Func action, Func comparisonOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, @@ -403,9 +405,8 @@ 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. private static string Execute(string givenInput, - string expectedOutput, - Action action, Func areEquivalentOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, string equivalentOperatorErrorMessage = "Values are not equal", @@ -426,6 +427,7 @@ 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. private static async Task ExecuteAsync(string givenInput, string expectedOutput, Func action, From e2c41126da60263b382d17ada9491cfeb2fb2142 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 01:52:50 -0700 Subject: [PATCH 5/8] DRY: extract WildcardMismatchMessage constant; remove redundant LikeOperator lambda wrap - Extract repeated 'The values are not like (using wildcards) each other' string into private const WildcardMismatchMessage; replaces 5 duplicate string literals - ExecuteProcess: pass LikeOperator directly instead of wrapping in an identical lambda --- IntelliTect.TestTools.Console/ConsoleAssert.cs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index 1e09778..5548db8 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -279,6 +279,8 @@ private static Task ExpectAsync( 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, @@ -293,7 +295,7 @@ private static Task ExpectAsync( public static string ExpectLike(string expected, char escapeCharacter, Action action) { return Expect(expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), - NormalizeOptions.Default, "The values are not like (using wildcards) each other", isWildcardMatching: true); + NormalizeOptions.Default, WildcardMismatchMessage, isWildcardMatching: true); } /// @@ -315,7 +317,7 @@ 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); } @@ -339,7 +341,7 @@ 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); } @@ -363,7 +365,7 @@ 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); } @@ -751,7 +753,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", isWildcardMatching: true); + AssertExpectation(expected, standardOutput, LikeOperator, WildcardMismatchMessage, isWildcardMatching: true); return process; } From 77f7c291df06e4e9f1f5c6abb4ae2c20fa3f44c7 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 08:36:34 -0700 Subject: [PATCH 6/8] DRY follow-ups: collapse * early-returns, EscapeForDisplay delegates to EscapeChar, extract test helpers - WildcardMatchAnalyzer: collapse two identical early-exit paths in ExtractWildcardMatches into one combined condition (nextPatternPos >= Length || nextLiteralIndex == -1); compute FindNextLiteralMatch once before the guard. - WildcardMatchAnalyzer: EscapeForDisplay now iterates chars and delegates to EscapeChar via StringBuilder, eliminating three chained .Replace calls. - EnhancedErrorMessageTests: extract GetMismatchMessage() helper used by three tests. - WildcardMatchAnalyzerTests: extract GetDiff() helper used by two GenerateDetailedDiff tests. --- .../EnhancedErrorMessageTests.cs | 64 ++++++------------- .../WildcardMatchAnalyzerTests.cs | 38 +++++------ .../WildcardMatchAnalyzer.cs | 31 ++++----- 3 files changed, 48 insertions(+), 85 deletions(-) diff --git a/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs index 9452474..5c891b8 100644 --- a/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs +++ b/IntelliTect.TestTools.Console.Tests/EnhancedErrorMessageTests.cs @@ -6,50 +6,39 @@ 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() { - // Arrange - pattern with wildcards that will NOT match - string expected = "PING *(::1) 56 data bytes\n64 bytes from *"; - - // Act & Assert - ConsoleAssert throws ConsoleAssertException on mismatch - ConsoleAssertException exception = Assert.ThrowsExactly(() => - { - ConsoleAssert.ExpectLike(expected, () => - { - System.Console.Write("PING localhost(::1) WRONG data bytes\n64 bytes from localhost"); - }); - }); + string errorMessage = GetMismatchMessage( + "PING *(::1) 56 data bytes\n64 bytes from *", + () => System.Console.Write("PING localhost(::1) WRONG data bytes\n64 bytes from localhost")); - // Assert - Check that the error message contains the enhanced output - string errorMessage = exception.Message; - - // Should contain the line-by-line comparison header StringAssert.Contains(errorMessage, "Line-by-line comparison"); - - // Should contain status indicators - StringAssert.Contains(errorMessage, "❌"); // Lines don't match + StringAssert.Contains(errorMessage, "❌"); } [TestMethod] public void ExpectLike_ExtraLines_IdentifiedAsSuch() { - // Arrange - pattern expecting one line, but getting two - string expected = "Line 1"; - - // Act & Assert - ConsoleAssertException exception = Assert.ThrowsExactly(() => - { - ConsoleAssert.ExpectLike(expected, () => + string errorMessage = GetMismatchMessage( + "Line 1", + () => { System.Console.WriteLine("Line 1"); System.Console.WriteLine("Line 2"); }); - }); - // Assert - string errorMessage = exception.Message; - // With line-by-line comparison, we should see the extra line marked StringAssert.Contains(errorMessage, "Line 2: ❌"); StringAssert.Contains(errorMessage, "Unexpected extra line"); } @@ -57,27 +46,16 @@ public void ExpectLike_ExtraLines_IdentifiedAsSuch() [TestMethod] public void ExpectLike_MissingLines_IdentifiedAsSuch() { - // Arrange - pattern with wildcards expecting more lines - string expected = "Line 1\nLine *"; - - // Act & Assert - ConsoleAssertException exception = Assert.ThrowsExactly(() => - { - ConsoleAssert.ExpectLike(expected, () => - { - System.Console.WriteLine("Line 1"); - }); - }); + string errorMessage = GetMismatchMessage( + "Line 1\nLine *", + () => System.Console.WriteLine("Line 1")); - // Assert - string errorMessage = exception.Message; StringAssert.Contains(errorMessage, "Missing line"); } [TestMethod] public void ExpectLike_SuccessfulWildcardMatch_DoesNotThrow() { - // Verify the feature doesn't break the happy path 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 index 5f6e3a2..523b4c7 100644 --- a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs +++ b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs @@ -6,6 +6,18 @@ 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() { @@ -96,37 +108,19 @@ public void AnalyzeMatch_MissingLinesInActual_MarkedAsMissing() [TestMethod] public void GenerateDetailedDiff_CreatesReadableOutput() { - // Arrange - string expected = "Hello * world\nLine *"; - string actual = "Hello beautiful world\nLine 2"; - - var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); - - // Act - string diff = WildcardMatchAnalyzer.GenerateDetailedDiff(results); + string diff = GetDiff("Hello * world\nLine *", "Hello beautiful world\nLine 2"); - // Assert - Assert.IsFalse(string.IsNullOrEmpty(diff)); StringAssert.Contains(diff, "Line-by-line comparison"); - StringAssert.Contains(diff, "✅"); // Should contain success markers + StringAssert.Contains(diff, "✅"); StringAssert.Contains(diff, "Wildcard matches"); } [TestMethod] public void GenerateDetailedDiff_WithMismatch_ShowsFailure() { - // Arrange - string expected = "Expected text"; - string actual = "Actual text"; - - var results = WildcardMatchAnalyzer.AnalyzeMatch(expected, actual); + string diff = GetDiff("Expected text", "Actual text"); - // Act - string diff = WildcardMatchAnalyzer.GenerateDetailedDiff(results); - - // Assert - Assert.IsFalse(string.IsNullOrEmpty(diff)); - StringAssert.Contains(diff, "❌"); // Should contain failure marker + StringAssert.Contains(diff, "❌"); } [TestMethod] diff --git a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs index 29be5d8..7c0348f 100644 --- a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs +++ b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs @@ -210,24 +210,15 @@ private static void ExtractWildcardMatches(string pattern, string text, List= pattern.Length) - { - // '*' at the end matches everything remaining - wildcardMatches.Add(new WildcardMatch - { - Pattern = "*", - MatchedText = text.Substring(textPos) - }); - return; - } + // 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; - // Find where the next literal prefix of the remaining pattern appears in the text - string remainingPattern = pattern.Substring(nextPatternPos); - int nextLiteralIndex = FindNextLiteralMatch(text, textPos, remainingPattern); - - if (nextLiteralIndex == -1) + if (nextPatternPos >= pattern.Length || nextLiteralIndex == -1) { - // Could not find a match for the remaining pattern — '*' consumed the rest + // '*' at the end of pattern, or remaining pattern has no literal anchor — + // consume everything remaining in the text. wildcardMatches.Add(new WildcardMatch { Pattern = "*", @@ -384,10 +375,10 @@ private static string EscapeForDisplay(string text) if (text == null) return ""; if (text == string.Empty) return ""; - return text - .Replace("\r", "\\r") - .Replace("\n", "\\n") - .Replace("\t", "\\t"); + var sb = new StringBuilder(text.Length); + foreach (char c in text) + sb.Append(EscapeChar(c)); + return sb.ToString(); } /// From d1f0e5a47906fce5ed6d294a24b27e9c47a6a1e2 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 08:53:02 -0700 Subject: [PATCH 7/8] Fix round-5 review: restore Execute params, fix StarAtStart assertion, guard length-mismatch for wildcards - ConsoleAssert.cs: Execute() was missing 'string expectedOutput' and 'Action action' parameters (accidentally dropped during isWildcardMatching threading). Build was broken with CS1501 + two CS0103 errors. Restored both parameters. - ConsoleAssert.cs: Guard the length-mismatch message with !isWildcardMatching so that pattern-vs-output length comparisons are not shown for wildcard failures (misleading: pattern length is semantically unrelated to match success). - WildcardMatchAnalyzerTests.cs: AnalyzeMatch_StarAtStart_MatchesLeadingText expected 'long prefix ' (trailing space) but FindNextLiteralMatch anchors on ' end' so '*' captures 'long prefix' without the space. Corrected assertion. --- .../WildcardMatchAnalyzerTests.cs | 2 +- IntelliTect.TestTools.Console/ConsoleAssert.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs index 523b4c7..d20ae38 100644 --- a/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs +++ b/IntelliTect.TestTools.Console.Tests/WildcardMatchAnalyzerTests.cs @@ -183,7 +183,7 @@ public void AnalyzeMatch_StarAtStart_MatchesLeadingText() 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); + Assert.AreEqual("long prefix", results[0].WildcardMatches[0].MatchedText); } [TestMethod] diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index 5548db8..9e470da 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -409,6 +409,8 @@ private static string StripAnsiEscapeCodes(string input) /// 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. private static string Execute(string givenInput, + string expectedOutput, + Action action, Func areEquivalentOperator, NormalizeOptions normalizeOptions = NormalizeOptions.Default, string equivalentOperatorErrorMessage = "Values are not equal", @@ -581,7 +583,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(); From 9a58747ad69b4cc20dbb6f31e9d78eb0c51a83c7 Mon Sep 17 00:00:00 2001 From: Benjamin Michaelis Date: Wed, 13 May 2026 14:32:24 -0700 Subject: [PATCH 8/8] Address Copilot PR reviewer feedback - Thread escapeCharacter through full call chain so WildcardMatchAnalyzer uses the same escape semantics as the assertion (e.g. '\*' treated as literal '*' when escapeCharacter='\'). Chain: ExpectLike* -> Expect/ ExpectAsync -> Execute/ExecuteAsync -> CompareOutput -> AssertExpectation -> GetMessageText -> WildcardMatchAnalyzer.AnalyzeMatch -> MatchLineWithWildcards -> WildcardPattern(pattern, escapeCharacter). - Fix stale XML doc comment in ExtractWildcardMatches: the 'known limitation' claimed '?' after '*' is absorbed into the '*' match, but the implementation actually records '*' as empty and '?' as its own entry. Updated to document the actual behavior. - Remove stray ')' in char mismatch message format string (pre-existing typo in ConsoleAssert.GetMessageText). - Narrow broad catch(Exception) to catch(Exception) when not OutOfMemoryException so fatal/runtime exceptions are not swallowed while building the wildcard diff diagnostic message. --- .../ConsoleAssert.cs | 53 ++++++++++++------- .../WildcardMatchAnalyzer.cs | 17 +++--- 2 files changed, 43 insertions(+), 27 deletions(-) diff --git a/IntelliTect.TestTools.Console/ConsoleAssert.cs b/IntelliTect.TestTools.Console/ConsoleAssert.cs index 9e470da..0fe2af8 100644 --- a/IntelliTect.TestTools.Console/ConsoleAssert.cs +++ b/IntelliTect.TestTools.Console/ConsoleAssert.cs @@ -237,17 +237,19 @@ 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", - bool isWildcardMatching = false) + bool isWildcardMatching = false, + char escapeCharacter = '\\') { (string input, string output) = Parse(expected); return Execute(input, output, action, (left, right) => comparisonOperator(left, right), - normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching); + normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } /// @@ -263,17 +265,19 @@ 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", - bool isWildcardMatching = false) + bool isWildcardMatching = false, + char escapeCharacter = '\\') { (string input, string output) = Parse(expected); return ExecuteAsync(input, output, action, (left, right) => comparisonOperator(left, right), - normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching); + normalizeOptions, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } private static readonly Func LikeOperator = @@ -295,7 +299,7 @@ private static Task ExpectAsync( public static string ExpectLike(string expected, char escapeCharacter, Action action) { return Expect(expected, action, (pattern, output) => output.IsLike(pattern, escapeCharacter), - NormalizeOptions.Default, WildcardMismatchMessage, isWildcardMatching: true); + NormalizeOptions.Default, WildcardMismatchMessage, isWildcardMatching: true, escapeCharacter); } /// @@ -318,7 +322,8 @@ public static string ExpectLike(string expected, Action action, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings ? NormalizeOptions.NormalizeLineEndings : NormalizeOptions.None, WildcardMismatchMessage, - isWildcardMatching: true); + isWildcardMatching: true, + escapeCharacter); } /// @@ -342,7 +347,8 @@ public static string ExpectLike(string expected, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings, WildcardMismatchMessage, - isWildcardMatching: true); + isWildcardMatching: true, + escapeCharacter); } /// @@ -366,7 +372,8 @@ public static Task ExpectLikeAsync(string expected, (pattern, output) => output.IsLike(pattern, escapeCharacter), normalizeLineEndings, WildcardMismatchMessage, - isWildcardMatching: true); + isWildcardMatching: true, + escapeCharacter); } /// @@ -408,18 +415,20 @@ private static string StripAnsiEscapeCodes(string input) /// 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", - bool isWildcardMatching = false + bool isWildcardMatching = false, + char escapeCharacter = '\\' ) { string output = Execute(givenInput, action); - return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching); + return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } /// @@ -432,18 +441,20 @@ private static string Execute(string givenInput, /// 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", - bool isWildcardMatching = false + bool isWildcardMatching = false, + char escapeCharacter = '\\' ) { string output = await ExecuteAsync(givenInput, action); - return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching); + return CompareOutput(output, expectedOutput, normalizeOptions, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); } private static string CompareOutput( @@ -452,7 +463,8 @@ private static string CompareOutput( NormalizeOptions normalizeOptions, Func areEquivalentOperator, string equivalentOperatorErrorMessage, - bool isWildcardMatching = false) + bool isWildcardMatching = false, + char escapeCharacter = '\\') { if ((normalizeOptions & NormalizeOptions.NormalizeLineEndings) != 0) { @@ -466,7 +478,7 @@ private static string CompareOutput( expectedOutput = StripAnsiEscapeCodes(expectedOutput); } - AssertExpectation(expectedOutput, output, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching); + AssertExpectation(expectedOutput, output, areEquivalentOperator, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter); return output; } @@ -478,13 +490,14 @@ private static string CompareOutput( /// 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, bool isWildcardMatching = false) + string equivalentOperatorErrorMessage = null, bool isWildcardMatching = false, char escapeCharacter = '\\') { bool failTest = !areEquivalentOperator(expectedOutput, output); if (failTest) { - throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage, isWildcardMatching)); + throw new ConsoleAssertException(GetMessageText(expectedOutput, output, equivalentOperatorErrorMessage, isWildcardMatching, escapeCharacter)); } } @@ -571,7 +584,7 @@ public static async Task ExecuteAsync(string givenInput, Func acti } - private static string GetMessageText(string expectedOutput, string output, string equivalentOperatorErrorMessage = null, bool isWildcardMatching = false) + private static string GetMessageText(string expectedOutput, string output, string equivalentOperatorErrorMessage = null, bool isWildcardMatching = false, char escapeCharacter = '\\') { string result = ""; @@ -605,7 +618,7 @@ private static string GetMessageText(string expectedOutput, string output, strin { result += Environment.NewLine + $"Character {counter} did not match: " - + $"'{CSharpStringEncode(expectedOutput[counter])}' != '{CSharpStringEncode(output[counter])})'"; + + $"'{CSharpStringEncode(expectedOutput[counter])}' != '{CSharpStringEncode(output[counter])}'"; break; } @@ -618,10 +631,10 @@ private static string GetMessageText(string expectedOutput, string output, strin { try { - var matchResults = WildcardMatchAnalyzer.AnalyzeMatch(expectedOutput, output); + var matchResults = WildcardMatchAnalyzer.AnalyzeMatch(expectedOutput, output, escapeCharacter); result += WildcardMatchAnalyzer.GenerateDetailedDiff(matchResults); } - catch (Exception ex) + catch (Exception ex) when (ex is not OutOfMemoryException) { // Pattern analysis failed — inform user but don't crash the test runner result += Environment.NewLine + diff --git a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs index 7c0348f..7d9c3cb 100644 --- a/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs +++ b/IntelliTect.TestTools.Console/WildcardMatchAnalyzer.cs @@ -47,7 +47,10 @@ internal class WildcardMatch /// /// Analyzes wildcard pattern matching line by line and returns detailed results. /// - internal static List AnalyzeMatch(string expectedPattern, string actualText) + /// 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(); @@ -81,7 +84,7 @@ internal static List AnalyzeMatch(string expectedPattern, strin else { // Try to match the line and capture what wildcards matched - lineResult.IsMatch = MatchLineWithWildcards(expectedLine, actualLine, lineResult.WildcardMatches); + lineResult.IsMatch = MatchLineWithWildcards(expectedLine, actualLine, lineResult.WildcardMatches, escapeCharacter); } results.Add(lineResult); @@ -152,11 +155,11 @@ internal static string GenerateDetailedDiff(List matchResults) /// 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) + private static bool MatchLineWithWildcards(string pattern, string text, List wildcardMatches, char escapeCharacter) { try { - var wildcardPattern = new WildcardPattern(pattern); + var wildcardPattern = new WildcardPattern(pattern, escapeCharacter); bool isMatch = wildcardPattern.IsMatch(text); if (isMatch) @@ -188,9 +191,9 @@ private static bool MatchLineWithWildcards(string pattern, string text, List private static void ExtractWildcardMatches(string pattern, string text, List wildcardMatches) {