rdf-construct 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. rdf_construct/__init__.py +12 -0
  2. rdf_construct/__main__.py +0 -0
  3. rdf_construct/cli.py +1762 -0
  4. rdf_construct/core/__init__.py +33 -0
  5. rdf_construct/core/config.py +116 -0
  6. rdf_construct/core/ordering.py +219 -0
  7. rdf_construct/core/predicate_order.py +212 -0
  8. rdf_construct/core/profile.py +157 -0
  9. rdf_construct/core/selector.py +64 -0
  10. rdf_construct/core/serialiser.py +232 -0
  11. rdf_construct/core/utils.py +89 -0
  12. rdf_construct/cq/__init__.py +77 -0
  13. rdf_construct/cq/expectations.py +365 -0
  14. rdf_construct/cq/formatters/__init__.py +45 -0
  15. rdf_construct/cq/formatters/json.py +104 -0
  16. rdf_construct/cq/formatters/junit.py +104 -0
  17. rdf_construct/cq/formatters/text.py +146 -0
  18. rdf_construct/cq/loader.py +300 -0
  19. rdf_construct/cq/runner.py +321 -0
  20. rdf_construct/diff/__init__.py +59 -0
  21. rdf_construct/diff/change_types.py +214 -0
  22. rdf_construct/diff/comparator.py +338 -0
  23. rdf_construct/diff/filters.py +133 -0
  24. rdf_construct/diff/formatters/__init__.py +71 -0
  25. rdf_construct/diff/formatters/json.py +192 -0
  26. rdf_construct/diff/formatters/markdown.py +210 -0
  27. rdf_construct/diff/formatters/text.py +195 -0
  28. rdf_construct/docs/__init__.py +60 -0
  29. rdf_construct/docs/config.py +238 -0
  30. rdf_construct/docs/extractors.py +603 -0
  31. rdf_construct/docs/generator.py +360 -0
  32. rdf_construct/docs/renderers/__init__.py +7 -0
  33. rdf_construct/docs/renderers/html.py +803 -0
  34. rdf_construct/docs/renderers/json.py +390 -0
  35. rdf_construct/docs/renderers/markdown.py +628 -0
  36. rdf_construct/docs/search.py +278 -0
  37. rdf_construct/docs/templates/html/base.html.jinja +44 -0
  38. rdf_construct/docs/templates/html/class.html.jinja +152 -0
  39. rdf_construct/docs/templates/html/hierarchy.html.jinja +28 -0
  40. rdf_construct/docs/templates/html/index.html.jinja +110 -0
  41. rdf_construct/docs/templates/html/instance.html.jinja +90 -0
  42. rdf_construct/docs/templates/html/namespaces.html.jinja +37 -0
  43. rdf_construct/docs/templates/html/property.html.jinja +124 -0
  44. rdf_construct/docs/templates/html/single_page.html.jinja +169 -0
  45. rdf_construct/lint/__init__.py +75 -0
  46. rdf_construct/lint/config.py +214 -0
  47. rdf_construct/lint/engine.py +396 -0
  48. rdf_construct/lint/formatters.py +327 -0
  49. rdf_construct/lint/rules.py +692 -0
  50. rdf_construct/main.py +6 -0
  51. rdf_construct/puml2rdf/__init__.py +103 -0
  52. rdf_construct/puml2rdf/config.py +230 -0
  53. rdf_construct/puml2rdf/converter.py +420 -0
  54. rdf_construct/puml2rdf/merger.py +200 -0
  55. rdf_construct/puml2rdf/model.py +202 -0
  56. rdf_construct/puml2rdf/parser.py +565 -0
  57. rdf_construct/puml2rdf/validators.py +451 -0
  58. rdf_construct/shacl/__init__.py +56 -0
  59. rdf_construct/shacl/config.py +166 -0
  60. rdf_construct/shacl/converters.py +520 -0
  61. rdf_construct/shacl/generator.py +364 -0
  62. rdf_construct/shacl/namespaces.py +93 -0
  63. rdf_construct/stats/__init__.py +29 -0
  64. rdf_construct/stats/collector.py +178 -0
  65. rdf_construct/stats/comparator.py +298 -0
  66. rdf_construct/stats/formatters/__init__.py +83 -0
  67. rdf_construct/stats/formatters/json.py +38 -0
  68. rdf_construct/stats/formatters/markdown.py +153 -0
  69. rdf_construct/stats/formatters/text.py +186 -0
  70. rdf_construct/stats/metrics/__init__.py +26 -0
  71. rdf_construct/stats/metrics/basic.py +147 -0
  72. rdf_construct/stats/metrics/complexity.py +137 -0
  73. rdf_construct/stats/metrics/connectivity.py +130 -0
  74. rdf_construct/stats/metrics/documentation.py +128 -0
  75. rdf_construct/stats/metrics/hierarchy.py +207 -0
  76. rdf_construct/stats/metrics/properties.py +88 -0
  77. rdf_construct/uml/__init__.py +22 -0
  78. rdf_construct/uml/context.py +194 -0
  79. rdf_construct/uml/mapper.py +371 -0
  80. rdf_construct/uml/odm_renderer.py +789 -0
  81. rdf_construct/uml/renderer.py +684 -0
  82. rdf_construct/uml/uml_layout.py +393 -0
  83. rdf_construct/uml/uml_style.py +613 -0
  84. rdf_construct-0.2.0.dist-info/METADATA +431 -0
  85. rdf_construct-0.2.0.dist-info/RECORD +88 -0
  86. rdf_construct-0.2.0.dist-info/WHEEL +4 -0
  87. rdf_construct-0.2.0.dist-info/entry_points.txt +3 -0
  88. rdf_construct-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,365 @@
1
+ """Expectation types and matching logic for competency question tests.
2
+
3
+ Supports various expectation styles:
4
+ - Boolean: ASK queries returning true/false
5
+ - Existence: has_results, no_results
6
+ - Count: exact, min, max result counts
7
+ - Values: specific result bindings
8
+ - Contains: subset matching
9
+ """
10
+
11
+ from abc import ABC, abstractmethod
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ from rdflib import URIRef, Literal, BNode
16
+ from rdflib.query import Result
17
+
18
+
19
+ @dataclass
20
+ class CheckResult:
21
+ """Result of an expectation check.
22
+
23
+ Attributes:
24
+ passed: Whether the expectation was met
25
+ message: Human-readable explanation of the result
26
+ expected: String representation of expected value
27
+ actual: String representation of actual value
28
+ """
29
+ passed: bool
30
+ message: str
31
+ expected: str = ""
32
+ actual: str = ""
33
+
34
+
35
+ class Expectation(ABC):
36
+ """Abstract base class for all expectation types."""
37
+
38
+ @abstractmethod
39
+ def check(self, result: Result) -> CheckResult:
40
+ """Check if the query result meets this expectation.
41
+
42
+ Args:
43
+ result: SPARQL query result from rdflib
44
+
45
+ Returns:
46
+ CheckResult with pass/fail status and explanation
47
+ """
48
+ ...
49
+
50
+ @abstractmethod
51
+ def describe(self) -> str:
52
+ """Return a human-readable description of this expectation."""
53
+ ...
54
+
55
+
56
+ class BooleanExpectation(Expectation):
57
+ """Expectation for ASK queries returning true/false."""
58
+
59
+ def __init__(self, expected: bool):
60
+ self.expected = expected
61
+
62
+ def check(self, result: Result) -> CheckResult:
63
+ # ASK queries return a boolean result
64
+ actual = bool(result)
65
+ passed = actual == self.expected
66
+
67
+ return CheckResult(
68
+ passed=passed,
69
+ message="ASK query matched" if passed else "ASK query did not match",
70
+ expected=str(self.expected),
71
+ actual=str(actual),
72
+ )
73
+
74
+ def describe(self) -> str:
75
+ return f"ASK = {self.expected}"
76
+
77
+
78
+ class HasResultsExpectation(Expectation):
79
+ """Expectation that a query returns at least one result."""
80
+
81
+ def check(self, result: Result) -> CheckResult:
82
+ # Convert to list to count (consumes the iterator)
83
+ results_list = list(result)
84
+ count = len(results_list)
85
+ passed = count > 0
86
+
87
+ return CheckResult(
88
+ passed=passed,
89
+ message=f"Found {count} result(s)" if passed else "No results found",
90
+ expected="≥1 results",
91
+ actual=f"{count} results",
92
+ )
93
+
94
+ def describe(self) -> str:
95
+ return "has results"
96
+
97
+
98
+ class NoResultsExpectation(Expectation):
99
+ """Expectation that a query returns zero results."""
100
+
101
+ def check(self, result: Result) -> CheckResult:
102
+ results_list = list(result)
103
+ count = len(results_list)
104
+ passed = count == 0
105
+
106
+ return CheckResult(
107
+ passed=passed,
108
+ message="No results (as expected)" if passed else f"Expected no results, got {count}",
109
+ expected="0 results",
110
+ actual=f"{count} results",
111
+ )
112
+
113
+ def describe(self) -> str:
114
+ return "no results"
115
+
116
+
117
+ @dataclass
118
+ class CountExpectation(Expectation):
119
+ """Expectation for specific result counts.
120
+
121
+ Attributes:
122
+ exact: Exact count required (None if not specified)
123
+ min_count: Minimum count (inclusive)
124
+ max_count: Maximum count (inclusive)
125
+ """
126
+ exact: int | None = None
127
+ min_count: int | None = None
128
+ max_count: int | None = None
129
+
130
+ def check(self, result: Result) -> CheckResult:
131
+ results_list = list(result)
132
+ actual = len(results_list)
133
+
134
+ if self.exact is not None:
135
+ passed = actual == self.exact
136
+ expected_str = f"exactly {self.exact}"
137
+ else:
138
+ passed = True
139
+ expected_parts = []
140
+
141
+ if self.min_count is not None:
142
+ if actual < self.min_count:
143
+ passed = False
144
+ expected_parts.append(f"≥{self.min_count}")
145
+
146
+ if self.max_count is not None:
147
+ if actual > self.max_count:
148
+ passed = False
149
+ expected_parts.append(f"≤{self.max_count}")
150
+
151
+ expected_str = " and ".join(expected_parts) if expected_parts else "any count"
152
+
153
+ if passed:
154
+ message = f"Count {actual} matches expectation"
155
+ else:
156
+ message = f"Count mismatch: expected {expected_str}, got {actual}"
157
+
158
+ return CheckResult(
159
+ passed=passed,
160
+ message=message,
161
+ expected=expected_str,
162
+ actual=str(actual),
163
+ )
164
+
165
+ def describe(self) -> str:
166
+ if self.exact is not None:
167
+ return f"count = {self.exact}"
168
+ parts = []
169
+ if self.min_count is not None:
170
+ parts.append(f"≥{self.min_count}")
171
+ if self.max_count is not None:
172
+ parts.append(f"≤{self.max_count}")
173
+ return "count " + " and ".join(parts) if parts else "any count"
174
+
175
+
176
+ @dataclass
177
+ class ValuesExpectation(Expectation):
178
+ """Expectation for exact result values.
179
+
180
+ Checks that results match a specific set of bindings exactly.
181
+ """
182
+ expected_results: list[dict[str, Any]] = field(default_factory=list)
183
+
184
+ def check(self, result: Result) -> CheckResult:
185
+ results_list = list(result)
186
+ actual_bindings = [self._normalize_row(row) for row in results_list]
187
+ expected_bindings = [self._normalize_dict(d) for d in self.expected_results]
188
+
189
+ # Sort for comparison
190
+ actual_sorted = sorted(actual_bindings, key=lambda x: str(sorted(x.items())))
191
+ expected_sorted = sorted(expected_bindings, key=lambda x: str(sorted(x.items())))
192
+
193
+ passed = actual_sorted == expected_sorted
194
+
195
+ if passed:
196
+ message = f"All {len(expected_bindings)} expected result(s) matched"
197
+ else:
198
+ message = "Result values do not match expected"
199
+
200
+ return CheckResult(
201
+ passed=passed,
202
+ message=message,
203
+ expected=str(expected_bindings),
204
+ actual=str(actual_bindings),
205
+ )
206
+
207
+ def _normalize_row(self, row) -> dict[str, str]:
208
+ """Normalize a result row for comparison."""
209
+ # rdflib ResultRow has asdict() method
210
+ if hasattr(row, 'asdict'):
211
+ row_dict = row.asdict()
212
+ else:
213
+ row_dict = dict(row)
214
+ return {str(k): self._term_to_string(v) for k, v in row_dict.items()}
215
+
216
+ def _normalize_dict(self, d: dict) -> dict[str, str]:
217
+ """Normalize an expected dict for comparison."""
218
+ return {str(k): str(v) for k, v in d.items()}
219
+
220
+ def _term_to_string(self, term: Any) -> str:
221
+ """Convert an RDF term to a comparable string."""
222
+ if isinstance(term, URIRef):
223
+ return str(term)
224
+ elif isinstance(term, Literal):
225
+ return str(term.toPython()) if term.toPython() is not None else str(term)
226
+ elif isinstance(term, BNode):
227
+ return f"_:{term}"
228
+ return str(term)
229
+
230
+ def describe(self) -> str:
231
+ return f"values = {len(self.expected_results)} row(s)"
232
+
233
+
234
+ @dataclass
235
+ class ContainsExpectation(Expectation):
236
+ """Expectation that results contain certain bindings (subset match).
237
+
238
+ Unlike ValuesExpectation, this doesn't require exact match -
239
+ it only checks that the expected bindings are present.
240
+ """
241
+ expected_bindings: list[dict[str, Any]] = field(default_factory=list)
242
+
243
+ def check(self, result: Result) -> CheckResult:
244
+ results_list = list(result)
245
+ actual_bindings = [self._normalize_row(row) for row in results_list]
246
+
247
+ missing = []
248
+ for expected in self.expected_bindings:
249
+ normalized_expected = self._normalize_dict(expected)
250
+ found = False
251
+ for actual in actual_bindings:
252
+ if self._matches(actual, normalized_expected):
253
+ found = True
254
+ break
255
+ if not found:
256
+ missing.append(normalized_expected)
257
+
258
+ passed = len(missing) == 0
259
+
260
+ if passed:
261
+ message = f"All {len(self.expected_bindings)} expected binding(s) found"
262
+ else:
263
+ message = f"Missing {len(missing)} expected binding(s)"
264
+
265
+ return CheckResult(
266
+ passed=passed,
267
+ message=message,
268
+ expected=str(self.expected_bindings),
269
+ actual=f"Missing: {missing}" if missing else "All present",
270
+ )
271
+
272
+ def _matches(self, actual: dict[str, str], expected: dict[str, str]) -> bool:
273
+ """Check if actual contains all expected key-value pairs."""
274
+ for key, value in expected.items():
275
+ if key not in actual or actual[key] != value:
276
+ return False
277
+ return True
278
+
279
+ def _normalize_row(self, row) -> dict[str, str]:
280
+ """Normalize a result row for comparison."""
281
+ # rdflib ResultRow has asdict() method
282
+ if hasattr(row, 'asdict'):
283
+ row_dict = row.asdict()
284
+ else:
285
+ row_dict = dict(row)
286
+ return {str(k): self._term_to_string(v) for k, v in row_dict.items()}
287
+
288
+ def _normalize_dict(self, d: dict) -> dict[str, str]:
289
+ """Normalize an expected dict for comparison."""
290
+ return {str(k): str(v) for k, v in d.items()}
291
+
292
+ def _term_to_string(self, term: Any) -> str:
293
+ """Convert an RDF term to a comparable string."""
294
+ if isinstance(term, URIRef):
295
+ return str(term)
296
+ elif isinstance(term, Literal):
297
+ return str(term.toPython()) if term.toPython() is not None else str(term)
298
+ elif isinstance(term, BNode):
299
+ return f"_:{term}"
300
+ return str(term)
301
+
302
+ def describe(self) -> str:
303
+ return f"contains {len(self.expected_bindings)} binding(s)"
304
+
305
+
306
+ def parse_expectation(expect: Any) -> Expectation:
307
+ """Parse an expectation from YAML configuration.
308
+
309
+ Supports multiple formats:
310
+ - Boolean: true/false for ASK queries
311
+ - String: "has_results" or "no_results"
312
+ - Dict with count: {"count": 5}, {"min_results": 1}, {"max_results": 10}
313
+ - Dict with results: {"results": [{"var": "value"}]}
314
+ - Dict with contains: {"contains": [{"var": "value"}]}
315
+
316
+ Args:
317
+ expect: Raw expectation value from YAML
318
+
319
+ Returns:
320
+ Appropriate Expectation subclass instance
321
+
322
+ Raises:
323
+ ValueError: If expectation format is unrecognised
324
+ """
325
+ # Boolean for ASK queries
326
+ if isinstance(expect, bool):
327
+ return BooleanExpectation(expect)
328
+
329
+ # String shortcuts
330
+ if isinstance(expect, str):
331
+ if expect == "has_results":
332
+ return HasResultsExpectation()
333
+ elif expect == "no_results":
334
+ return NoResultsExpectation()
335
+ elif expect.lower() == "true":
336
+ return BooleanExpectation(True)
337
+ elif expect.lower() == "false":
338
+ return BooleanExpectation(False)
339
+ else:
340
+ raise ValueError(f"Unknown expectation string: {expect}")
341
+
342
+ # Dict with specific keys
343
+ if isinstance(expect, dict):
344
+ # Exact count
345
+ if "count" in expect:
346
+ return CountExpectation(exact=expect["count"])
347
+
348
+ # Min/max count
349
+ if "min_results" in expect or "max_results" in expect:
350
+ return CountExpectation(
351
+ min_count=expect.get("min_results"),
352
+ max_count=expect.get("max_results"),
353
+ )
354
+
355
+ # Exact values
356
+ if "results" in expect:
357
+ return ValuesExpectation(expected_results=expect["results"])
358
+
359
+ # Subset contains
360
+ if "contains" in expect:
361
+ return ContainsExpectation(expected_bindings=expect["contains"])
362
+
363
+ raise ValueError(f"Unknown expectation dict keys: {expect.keys()}")
364
+
365
+ raise ValueError(f"Unknown expectation format: {type(expect).__name__}")
@@ -0,0 +1,45 @@
1
+ """Output formatters for competency question test results.
2
+
3
+ Supports:
4
+ - text: Human-readable console output
5
+ - json: Structured JSON for programmatic use
6
+ - junit: JUnit XML for CI integration
7
+ """
8
+
9
+ from rdf_construct.cq.formatters.text import format_text
10
+ from rdf_construct.cq.formatters.json import format_json
11
+ from rdf_construct.cq.formatters.junit import format_junit
12
+
13
+ __all__ = [
14
+ "format_text",
15
+ "format_json",
16
+ "format_junit",
17
+ ]
18
+
19
+
20
+ def format_results(results, format_name: str = "text",
21
+ verbose: bool = False) -> str:
22
+ """Format test results using the specified formatter.
23
+
24
+ Args:
25
+ results: CQTestResults to format
26
+ format_name: One of "text", "json", "junit"
27
+ verbose: Include verbose details
28
+
29
+ Returns:
30
+ Formatted string
31
+
32
+ Raises:
33
+ ValueError: If format_name is unknown
34
+ """
35
+ formatters = {
36
+ "text": lambda r, v: format_text(r, verbose=v),
37
+ "json": lambda r, v: format_json(r, verbose=v),
38
+ "junit": lambda r, v: format_junit(r, verbose=v),
39
+ }
40
+
41
+ if format_name not in formatters:
42
+ valid = ", ".join(formatters.keys())
43
+ raise ValueError(f"Unknown format '{format_name}'. Valid formats: {valid}")
44
+
45
+ return formatters[format_name](results, verbose)
@@ -0,0 +1,104 @@
1
+ """JSON output formatter for competency question test results.
2
+
3
+ Produces structured JSON for programmatic consumption.
4
+ """
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from rdf_construct.cq.runner import CQTestResults, CQTestResult, CQStatus
10
+
11
+
12
+ def format_json(results: CQTestResults, verbose: bool = False,
13
+ indent: int = 2) -> str:
14
+ """Format test results as JSON.
15
+
16
+ Args:
17
+ results: Test results to format
18
+ verbose: Include additional details
19
+ indent: JSON indentation level (0 for compact)
20
+
21
+ Returns:
22
+ JSON string
23
+ """
24
+ data = _build_json_data(results, verbose)
25
+ return json.dumps(data, indent=indent if indent > 0 else None, default=str)
26
+
27
+
28
+ def _build_json_data(results: CQTestResults, verbose: bool) -> dict[str, Any]:
29
+ """Build the JSON data structure."""
30
+ data: dict[str, Any] = {}
31
+
32
+ # Metadata
33
+ if results.ontology_file:
34
+ data["ontology"] = str(results.ontology_file)
35
+
36
+ if results.suite.name:
37
+ data["suite_name"] = results.suite.name
38
+
39
+ if results.suite.version:
40
+ data["suite_version"] = results.suite.version
41
+
42
+ # Test results
43
+ data["questions"] = [
44
+ _format_result(r, verbose) for r in results.results
45
+ ]
46
+
47
+ # Summary
48
+ data["summary"] = {
49
+ "total": results.total_count,
50
+ "passed": results.passed_count,
51
+ "failed": results.failed_count,
52
+ "errors": results.error_count,
53
+ "skipped": results.skipped_count,
54
+ }
55
+
56
+ if verbose:
57
+ data["summary"]["duration_ms"] = round(results.total_duration_ms, 2)
58
+
59
+ return data
60
+
61
+
62
+ def _format_result(result: CQTestResult, verbose: bool) -> dict[str, Any]:
63
+ """Format a single test result as a dict."""
64
+ data: dict[str, Any] = {
65
+ "id": result.test.id,
66
+ "name": result.test.name,
67
+ "status": result.status.value,
68
+ }
69
+
70
+ # Add tags if present
71
+ if result.test.tags:
72
+ data["tags"] = result.test.tags
73
+
74
+ # Add timing
75
+ if result.duration_ms:
76
+ data["duration_ms"] = round(result.duration_ms, 2)
77
+
78
+ # Add result count for SELECT queries
79
+ if result.result_count is not None:
80
+ data["result_count"] = result.result_count
81
+
82
+ # Add failure details
83
+ if result.status == CQStatus.FAIL and result.check_result:
84
+ data["expected"] = result.check_result.expected
85
+ data["actual"] = result.check_result.actual
86
+ data["message"] = result.check_result.message
87
+
88
+ # Add error details
89
+ if result.status == CQStatus.ERROR and result.error:
90
+ data["error"] = result.error
91
+
92
+ # Add skip reason
93
+ if result.status == CQStatus.SKIP and result.test.skip_reason:
94
+ data["skip_reason"] = result.test.skip_reason
95
+
96
+ # Add verbose details
97
+ if verbose:
98
+ if result.test.description:
99
+ data["description"] = result.test.description
100
+
101
+ # Include the query in verbose mode
102
+ data["query"] = result.test.query.strip()
103
+
104
+ return data
@@ -0,0 +1,104 @@
1
+ """JUnit XML output formatter for competency question test results.
2
+
3
+ Produces JUnit XML format for CI integration (GitHub Actions, GitLab CI, Jenkins).
4
+ """
5
+
6
+ import xml.etree.ElementTree as ET
7
+ from xml.dom import minidom
8
+
9
+ from rdf_construct.cq.runner import CQTestResults, CQTestResult, CQStatus
10
+
11
+
12
+ def format_junit(results: CQTestResults, verbose: bool = False) -> str:
13
+ """Format test results as JUnit XML.
14
+
15
+ Args:
16
+ results: Test results to format
17
+ verbose: Include additional details in output
18
+
19
+ Returns:
20
+ JUnit XML string
21
+ """
22
+ # Build testsuite element
23
+ testsuite = ET.Element("testsuite")
24
+
25
+ # Set testsuite attributes
26
+ if results.suite.name:
27
+ testsuite.set("name", results.suite.name)
28
+ else:
29
+ testsuite.set("name", "Competency Questions")
30
+
31
+ testsuite.set("tests", str(results.total_count))
32
+ testsuite.set("failures", str(results.failed_count))
33
+ testsuite.set("errors", str(results.error_count))
34
+ testsuite.set("skipped", str(results.skipped_count))
35
+ testsuite.set("time", f"{results.total_duration_ms / 1000:.3f}")
36
+
37
+ if results.ontology_file:
38
+ testsuite.set("file", str(results.ontology_file))
39
+
40
+ # Add testcase elements
41
+ for result in results.results:
42
+ testcase = _format_testcase(result, verbose)
43
+ testsuite.append(testcase)
44
+
45
+ # Convert to string with pretty printing
46
+ rough_string = ET.tostring(testsuite, encoding="unicode")
47
+ reparsed = minidom.parseString(rough_string)
48
+ return reparsed.toprettyxml(indent=" ", encoding=None)
49
+
50
+
51
+ def _format_testcase(result: CQTestResult, verbose: bool) -> ET.Element:
52
+ """Format a single test result as a testcase element."""
53
+ testcase = ET.Element("testcase")
54
+
55
+ # Required attributes
56
+ testcase.set("name", f"{result.test.id}: {result.test.name}")
57
+ testcase.set("classname", "CompetencyQuestions")
58
+ testcase.set("time", f"{result.duration_ms / 1000:.3f}")
59
+
60
+ # Status-specific content
61
+ if result.status == CQStatus.FAIL:
62
+ failure = ET.SubElement(testcase, "failure")
63
+ if result.check_result:
64
+ failure.set("message", result.check_result.message)
65
+ failure.text = (
66
+ f"Expected: {result.check_result.expected}\n"
67
+ f"Actual: {result.check_result.actual}"
68
+ )
69
+ else:
70
+ failure.set("message", "Test failed")
71
+
72
+ elif result.status == CQStatus.ERROR:
73
+ error = ET.SubElement(testcase, "error")
74
+ if result.error:
75
+ error.set("message", result.error)
76
+ error.set("type", "QueryError")
77
+ error.text = result.error
78
+ else:
79
+ error.set("message", "Unknown error")
80
+
81
+ elif result.status == CQStatus.SKIP:
82
+ skipped = ET.SubElement(testcase, "skipped")
83
+ if result.test.skip_reason:
84
+ skipped.set("message", result.test.skip_reason)
85
+
86
+ # Add system-out for verbose mode
87
+ if verbose:
88
+ system_out = ET.SubElement(testcase, "system-out")
89
+ output_lines = []
90
+
91
+ if result.test.description:
92
+ output_lines.append(f"Description: {result.test.description}")
93
+
94
+ if result.test.tags:
95
+ output_lines.append(f"Tags: {', '.join(result.test.tags)}")
96
+
97
+ if result.result_count is not None:
98
+ output_lines.append(f"Result count: {result.result_count}")
99
+
100
+ output_lines.append(f"Query:\n{result.test.query}")
101
+
102
+ system_out.text = "\n".join(output_lines)
103
+
104
+ return testcase