| 1 | """ |
| 2 | Output matching utilities for interactive tests. |
| 3 | |
| 4 | Provides flexible matching for verifying shell output, supporting |
| 5 | exact matches, substrings, regex patterns, and structured output. |
| 6 | """ |
| 7 | |
| 8 | import re |
| 9 | from typing import Union, List, Optional |
| 10 | from dataclasses import dataclass |
| 11 | |
| 12 | |
| 13 | @dataclass |
| 14 | class MatchResult: |
| 15 | """Result of a match operation.""" |
| 16 | matched: bool |
| 17 | message: str |
| 18 | actual: str |
| 19 | expected: str |
| 20 | |
| 21 | |
| 22 | def match_exact(actual: str, expected: str) -> MatchResult: |
| 23 | """ |
| 24 | Match output exactly (after stripping whitespace). |
| 25 | |
| 26 | Args: |
| 27 | actual: Actual output |
| 28 | expected: Expected output |
| 29 | |
| 30 | Returns: |
| 31 | MatchResult indicating success/failure |
| 32 | """ |
| 33 | actual_stripped = actual.strip() |
| 34 | expected_stripped = expected.strip() |
| 35 | |
| 36 | if actual_stripped == expected_stripped: |
| 37 | return MatchResult(True, "Exact match", actual_stripped, expected_stripped) |
| 38 | |
| 39 | return MatchResult( |
| 40 | False, |
| 41 | f"Exact match failed", |
| 42 | actual_stripped, |
| 43 | expected_stripped |
| 44 | ) |
| 45 | |
| 46 | |
| 47 | def match_contains(actual: str, expected: str) -> MatchResult: |
| 48 | """ |
| 49 | Check if output contains expected substring. |
| 50 | |
| 51 | Args: |
| 52 | actual: Actual output |
| 53 | expected: Expected substring |
| 54 | |
| 55 | Returns: |
| 56 | MatchResult indicating success/failure |
| 57 | """ |
| 58 | if expected in actual: |
| 59 | return MatchResult(True, "Contains match", actual, expected) |
| 60 | |
| 61 | return MatchResult( |
| 62 | False, |
| 63 | f"Substring not found", |
| 64 | actual, |
| 65 | expected |
| 66 | ) |
| 67 | |
| 68 | |
| 69 | def match_regex(actual: str, pattern: str, flags: int = 0) -> MatchResult: |
| 70 | """ |
| 71 | Match output against a regex pattern. |
| 72 | |
| 73 | Args: |
| 74 | actual: Actual output |
| 75 | pattern: Regex pattern |
| 76 | flags: Regex flags (e.g., re.IGNORECASE) |
| 77 | |
| 78 | Returns: |
| 79 | MatchResult indicating success/failure |
| 80 | """ |
| 81 | try: |
| 82 | if re.search(pattern, actual, flags): |
| 83 | return MatchResult(True, "Regex match", actual, pattern) |
| 84 | return MatchResult(False, "Regex not matched", actual, pattern) |
| 85 | except re.error as e: |
| 86 | return MatchResult(False, f"Invalid regex: {e}", actual, pattern) |
| 87 | |
| 88 | |
| 89 | def match_lines(actual: str, expected_lines: List[str]) -> MatchResult: |
| 90 | """ |
| 91 | Match output line by line. |
| 92 | |
| 93 | Args: |
| 94 | actual: Actual output |
| 95 | expected_lines: List of expected lines |
| 96 | |
| 97 | Returns: |
| 98 | MatchResult indicating success/failure |
| 99 | """ |
| 100 | actual_lines = actual.strip().split('\n') |
| 101 | |
| 102 | if len(actual_lines) != len(expected_lines): |
| 103 | return MatchResult( |
| 104 | False, |
| 105 | f"Line count mismatch: got {len(actual_lines)}, expected {len(expected_lines)}", |
| 106 | '\n'.join(actual_lines), |
| 107 | '\n'.join(expected_lines) |
| 108 | ) |
| 109 | |
| 110 | for i, (act, exp) in enumerate(zip(actual_lines, expected_lines)): |
| 111 | if act.strip() != exp.strip(): |
| 112 | return MatchResult( |
| 113 | False, |
| 114 | f"Line {i+1} mismatch", |
| 115 | act.strip(), |
| 116 | exp.strip() |
| 117 | ) |
| 118 | |
| 119 | return MatchResult( |
| 120 | True, |
| 121 | "All lines match", |
| 122 | '\n'.join(actual_lines), |
| 123 | '\n'.join(expected_lines) |
| 124 | ) |
| 125 | |
| 126 | |
| 127 | def match_startswith(actual: str, prefix: str) -> MatchResult: |
| 128 | """ |
| 129 | Check if output starts with expected prefix. |
| 130 | |
| 131 | Args: |
| 132 | actual: Actual output |
| 133 | prefix: Expected prefix |
| 134 | |
| 135 | Returns: |
| 136 | MatchResult indicating success/failure |
| 137 | """ |
| 138 | stripped = actual.strip() |
| 139 | if stripped.startswith(prefix): |
| 140 | return MatchResult(True, "Prefix match", stripped, prefix) |
| 141 | |
| 142 | return MatchResult( |
| 143 | False, |
| 144 | "Prefix not found", |
| 145 | stripped[:len(prefix)+20] + "..." if len(stripped) > len(prefix)+20 else stripped, |
| 146 | prefix |
| 147 | ) |
| 148 | |
| 149 | |
| 150 | def match_endswith(actual: str, suffix: str) -> MatchResult: |
| 151 | """ |
| 152 | Check if output ends with expected suffix. |
| 153 | |
| 154 | Args: |
| 155 | actual: Actual output |
| 156 | suffix: Expected suffix |
| 157 | |
| 158 | Returns: |
| 159 | MatchResult indicating success/failure |
| 160 | """ |
| 161 | stripped = actual.strip() |
| 162 | if stripped.endswith(suffix): |
| 163 | return MatchResult(True, "Suffix match", stripped, suffix) |
| 164 | |
| 165 | return MatchResult( |
| 166 | False, |
| 167 | "Suffix not found", |
| 168 | "..." + stripped[-(len(suffix)+20):] if len(stripped) > len(suffix)+20 else stripped, |
| 169 | suffix |
| 170 | ) |
| 171 | |
| 172 | |
| 173 | def match_not_contains(actual: str, unwanted: str) -> MatchResult: |
| 174 | """ |
| 175 | Verify output does NOT contain a substring. |
| 176 | |
| 177 | Args: |
| 178 | actual: Actual output |
| 179 | unwanted: Substring that should not be present |
| 180 | |
| 181 | Returns: |
| 182 | MatchResult indicating success/failure |
| 183 | """ |
| 184 | if unwanted not in actual: |
| 185 | return MatchResult(True, "Correctly absent", actual, f"NOT {unwanted}") |
| 186 | |
| 187 | return MatchResult( |
| 188 | False, |
| 189 | f"Unwanted substring found", |
| 190 | actual, |
| 191 | f"NOT {unwanted}" |
| 192 | ) |
| 193 | |
| 194 | |
| 195 | def match_empty(actual: str) -> MatchResult: |
| 196 | """ |
| 197 | Verify output is empty (after stripping whitespace). |
| 198 | |
| 199 | Args: |
| 200 | actual: Actual output |
| 201 | |
| 202 | Returns: |
| 203 | MatchResult indicating success/failure |
| 204 | """ |
| 205 | stripped = actual.strip() |
| 206 | if not stripped: |
| 207 | return MatchResult(True, "Output is empty", stripped, "<empty>") |
| 208 | |
| 209 | return MatchResult( |
| 210 | False, |
| 211 | "Output is not empty", |
| 212 | stripped, |
| 213 | "<empty>" |
| 214 | ) |
| 215 | |
| 216 | |
| 217 | def match_not_empty(actual: str) -> MatchResult: |
| 218 | """ |
| 219 | Verify output is not empty. |
| 220 | |
| 221 | Args: |
| 222 | actual: Actual output |
| 223 | |
| 224 | Returns: |
| 225 | MatchResult indicating success/failure |
| 226 | """ |
| 227 | stripped = actual.strip() |
| 228 | if stripped: |
| 229 | return MatchResult(True, "Output is not empty", stripped, "<non-empty>") |
| 230 | |
| 231 | return MatchResult( |
| 232 | False, |
| 233 | "Output is unexpectedly empty", |
| 234 | stripped, |
| 235 | "<non-empty>" |
| 236 | ) |
| 237 | |
| 238 | |
| 239 | class OutputMatcher: |
| 240 | """ |
| 241 | Flexible output matcher supporting multiple match types. |
| 242 | |
| 243 | Usage: |
| 244 | matcher = OutputMatcher() |
| 245 | result = matcher.match(output, expected="hello", match_type="contains") |
| 246 | |
| 247 | # Or use the builder pattern: |
| 248 | result = matcher.contains("hello").match(output) |
| 249 | """ |
| 250 | |
| 251 | def __init__(self): |
| 252 | self._match_type = "exact" |
| 253 | self._expected = "" |
| 254 | self._flags = 0 |
| 255 | |
| 256 | def exact(self, expected: str) -> 'OutputMatcher': |
| 257 | """Set up exact match.""" |
| 258 | self._match_type = "exact" |
| 259 | self._expected = expected |
| 260 | return self |
| 261 | |
| 262 | def contains(self, substring: str) -> 'OutputMatcher': |
| 263 | """Set up contains match.""" |
| 264 | self._match_type = "contains" |
| 265 | self._expected = substring |
| 266 | return self |
| 267 | |
| 268 | def regex(self, pattern: str, flags: int = 0) -> 'OutputMatcher': |
| 269 | """Set up regex match.""" |
| 270 | self._match_type = "regex" |
| 271 | self._expected = pattern |
| 272 | self._flags = flags |
| 273 | return self |
| 274 | |
| 275 | def startswith(self, prefix: str) -> 'OutputMatcher': |
| 276 | """Set up prefix match.""" |
| 277 | self._match_type = "startswith" |
| 278 | self._expected = prefix |
| 279 | return self |
| 280 | |
| 281 | def endswith(self, suffix: str) -> 'OutputMatcher': |
| 282 | """Set up suffix match.""" |
| 283 | self._match_type = "endswith" |
| 284 | self._expected = suffix |
| 285 | return self |
| 286 | |
| 287 | def match( |
| 288 | self, |
| 289 | actual: str, |
| 290 | expected: Optional[str] = None, |
| 291 | match_type: Optional[str] = None |
| 292 | ) -> MatchResult: |
| 293 | """ |
| 294 | Perform the match. |
| 295 | |
| 296 | Args: |
| 297 | actual: Actual output to match |
| 298 | expected: Expected value (overrides builder) |
| 299 | match_type: Match type (overrides builder) |
| 300 | |
| 301 | Returns: |
| 302 | MatchResult |
| 303 | """ |
| 304 | exp = expected if expected is not None else self._expected |
| 305 | mt = match_type if match_type is not None else self._match_type |
| 306 | |
| 307 | if mt == "exact": |
| 308 | return match_exact(actual, exp) |
| 309 | elif mt == "contains": |
| 310 | return match_contains(actual, exp) |
| 311 | elif mt == "regex": |
| 312 | return match_regex(actual, exp, self._flags) |
| 313 | elif mt == "startswith": |
| 314 | return match_startswith(actual, exp) |
| 315 | elif mt == "endswith": |
| 316 | return match_endswith(actual, exp) |
| 317 | elif mt == "empty": |
| 318 | return match_empty(actual) |
| 319 | elif mt == "not_empty": |
| 320 | return match_not_empty(actual) |
| 321 | elif mt == "not_contains": |
| 322 | return match_not_contains(actual, exp) |
| 323 | elif mt == "lines": |
| 324 | if isinstance(exp, str): |
| 325 | exp = exp.split('\n') |
| 326 | return match_lines(actual, exp) |
| 327 | else: |
| 328 | return MatchResult(False, f"Unknown match type: {mt}", actual, exp) |
| 329 | |
| 330 | |
| 331 | # Convenience functions for pytest-style assertions |
| 332 | def assert_output_equals(actual: str, expected: str, msg: str = "") -> None: |
| 333 | """Assert output equals expected (pytest-friendly).""" |
| 334 | result = match_exact(actual, expected) |
| 335 | if not result.matched: |
| 336 | raise AssertionError( |
| 337 | f"{msg}\n{result.message}\n" |
| 338 | f"Expected: {result.expected}\n" |
| 339 | f"Actual: {result.actual}" |
| 340 | ) |
| 341 | |
| 342 | |
| 343 | def assert_output_contains(actual: str, substring: str, msg: str = "") -> None: |
| 344 | """Assert output contains substring (pytest-friendly).""" |
| 345 | result = match_contains(actual, substring) |
| 346 | if not result.matched: |
| 347 | raise AssertionError( |
| 348 | f"{msg}\n{result.message}\n" |
| 349 | f"Expected to contain: {result.expected}\n" |
| 350 | f"Actual: {result.actual}" |
| 351 | ) |
| 352 | |
| 353 | |
| 354 | def assert_output_matches(actual: str, pattern: str, msg: str = "") -> None: |
| 355 | """Assert output matches regex pattern (pytest-friendly).""" |
| 356 | result = match_regex(actual, pattern) |
| 357 | if not result.matched: |
| 358 | raise AssertionError( |
| 359 | f"{msg}\n{result.message}\n" |
| 360 | f"Pattern: {result.expected}\n" |
| 361 | f"Actual: {result.actual}" |
| 362 | ) |