Unittest.py
Summary
- Lines:
280 of 305 covered (91.8%)
- Excluded lines:
3
- Branches:
67 of 90 taken (74.4%), 15 lines partially
- Coverage:
87.8%
- Directory:
- Missed lines:
279-280, 314-315, 322-323, 365, 367, 370-375, 392, 394, 396, 399-404, 445, 632-637
Source
pyTooling/Sphinx/Unittest.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2# _____ _ _ ____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ / ___| _ __ | |__ (_)_ __ __ __ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ / #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |> < #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\ #
7# |_| |___/ |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2023-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A directive rendering a unit test report as a table: per testsuite and testcase, the counts and the runtime.
33
34The reports are read by :mod:`pyEDAA.Reports`: a JUnit XML file in any dialect - e.g. the reports of pytest or
35OSVVM -, or a test report in pyTooling's own XML format, recognized by its root element ``<TestReport>``. The report
36files are declared in :file:`conf.py` under ``pyTooling_Unittest_Testsuites``, each with an identifier a directive
37names in its ``:reportid:`` option. An entry with the key ``pages`` gets a page per testsuite and testcase, generated
38below that document name (see :mod:`pyTooling.Sphinx.UnittestPages`):
39
40.. code-block:: Python
41
42 # doc/conf.py
43 pyTooling_Unittest_Testsuites = {
44 "src": {
45 "xml_report": "../report/unit/unittest.xml",
46 "pages": "unittests/src",
47 }
48 }
49
50The reports are read once, when the builder is initialized, and shared by every directive and page using them.
51
52.. seealso::
53
54 :ref:`DIR/UnittestSummary`
55 |rarr| The directive's options and configuration, with examples.
56"""
57from __future__ import annotations
58
59from datetime import timedelta
60from enum import Flag
61from pathlib import Path
62from typing import TYPE_CHECKING, Any, ClassVar, Generator, Mapping, Optional as Nullable
63from typing import TypedDict
64from xml.etree.ElementTree import iterparse # nosec B405 - reads the root element of the project's report
65
66from docutils import nodes
67from docutils.parsers.rst.directives import flag
68from sphinx.application import Sphinx
69from sphinx.config import Config
70from sphinx.util.logging import getLogger
71
72from pyTooling.Decorators import export
73
74from pyTooling.Sphinx import BaseDirective, ReportExtensionError
75from pyTooling.Sphinx import ReportsPackageMissingError, SphinxExtensionError
76from pyTooling.Sphinx import strip, stripAndNormalize
77from pyTooling.Sphinx.Node import Landscape, ReportIndentation
78from pyTooling.Sphinx.Pages import ReportPages
79
80if TYPE_CHECKING: # pragma: no cover
81 # pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
82 from pyEDAA.Reports.Unittesting import TestcaseStatus, TestsuiteStatus
83 from pyEDAA.Reports.Unittesting import Testcase, Testsuite, TestsuiteSummary
84
85
86__all__ = ["CONFIG_PREFIX", "REPORT_FORMATS"]
87
88#: Prefix every configuration value of the unit test directive carries in :file:`conf.py`.
89CONFIG_PREFIX = "pyTooling_Unittest"
90
91#: The report formats, by the root element of a report file.
92REPORT_FORMATS = {
93 "testsuites": "JUnit",
94 "testsuite": "JUnit",
95 "TestReport": "pyTooling",
96}
97
98
99@export
100class TestsuiteConfiguration(TypedDict):
101 """An entry of ``pyTooling_Unittest_Testsuites``, after :meth:`UnittestSummary.CheckConfiguration` read it."""
102
103 xml_report: Path #: The unit test report, in JUnit XML or pyTooling's XML format.
104 pages: Nullable[str] #: Document name the pages per testsuite and testcase are generated below, if any.
105
106
107@export
108class ShowTestcases(Flag):
109 """
110 Which testcases a unit test summary lists, by their status.
111
112 A member compares equal to a :class:`~pyEDAA.Reports.Unittesting.TestcaseStatus` it includes.
113 """
114
115 passed = 1 #: Passed testcases, and testcases failing as expected.
116 failed = 2 #: Failed testcases, and testcases passing unexpectedly.
117 skipped = 4 #: Skipped testcases.
118 excluded = 8 #: Excluded testcases.
119 errors = 16 #: Errored testcases, and testcases whose setup failed.
120 aborted = 32 #: Aborted testcases.
121
122 all = passed | failed | skipped | excluded | errors | aborted #: Every testcase.
123 not_passed = all & ~passed #: Every testcase but the passed ones.
124
125 def __eq__(self, other: Any) -> bool:
126 """
127 Check whether a testcase status is included.
128
129 :param other: A testcase status.
130 :returns: ``True`` if ``other`` is a :class:`~pyEDAA.Reports.Unittesting.TestcaseStatus` this member includes.
131 """
132 from pyEDAA.Reports.Unittesting import TestcaseStatus
133
134 if isinstance(other, TestcaseStatus):
135 if other is TestcaseStatus.Passed or other is TestcaseStatus.ExpectedFailed:
136 return ShowTestcases.passed in self
137 elif other is TestcaseStatus.Failed or other is TestcaseStatus.UnexpectedPassed:
138 return ShowTestcases.failed in self
139 elif other is TestcaseStatus.Skipped:
140 return ShowTestcases.skipped in self
141 elif other is TestcaseStatus.Excluded:
142 return ShowTestcases.excluded in self
143 elif other is TestcaseStatus.Errored or other is TestcaseStatus.SetupError:
144 return ShowTestcases.errors in self
145 elif other is TestcaseStatus.Aborted:
146 return ShowTestcases.aborted in self
147
148 return False
149
150
151@export
152class UnittestSummary(BaseDirective):
153 """
154 The ``report:unittest-summary`` directive: a table of a unit test report, per testsuite and testcase.
155 """
156
157 directiveName: str = "unittest-summary" #: Name the directive is invoked by.
158
159 has_content = False #: A boolean; ``True`` if content is allowed.
160 required_arguments = 0 #: Number of required directive arguments.
161 optional_arguments = 6 #: Number of optional arguments.
162
163 option_spec: dict[str, Any] = { # type: ignore[misc]
164 "class": strip,
165 "reportid": stripAndNormalize,
166 "testsuite-summary-name": strip,
167 "show-testcases": stripAndNormalize,
168 "no-assertions": flag,
169 "hide-testsuite-summary": flag
170 } #: Mapping of option names to validator functions.
171
172 configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
173 "Testsuites": ({}, "env", dict)
174 } #: Values added to :file:`conf.py`, as ``name: (default, rebuild, types)``, prefixed by :data:`CONFIG_PREFIX`.
175
176 _testSummaries: ClassVar[dict[str, TestsuiteConfiguration]] = {} #: Report configurations, by report ID.
177 _reports: ClassVar[dict[str, TestsuiteSummary]] = {} #: Reports read by :meth:`ReadReports`, by ID.
178 _readErrors: ClassVar[dict[str, Exception]] = {} #: Why a report couldn't be read, by report ID.
179
180 _cssClasses: list[str] #: Additional CSS classes of the table.
181 _reportID: str #: Identifier of the report in the configuration.
182 _noAssertions: bool #: Whether the assertion column is left out.
183 _hideTestsuiteSummary: bool #: Whether the testsuite summary's row is left out.
184 _testsuiteSummaryName: Nullable[str] #: Name replacing the testsuite summary's name, or ``""``.
185 _showTestcases: ShowTestcases #: Which testcases are listed.
186 _xmlReport: Path #: The unit test report.
187 _testsuite: TestsuiteSummary #: The report's testsuite summary.
188
189 def _CheckOptions(self) -> None:
190 """
191 Parse all directive options or use default values.
192
193 :raises SphinxExtensionError: If option ``:class:`` isn't a list of CSS class names.
194 :raises SphinxExtensionError: If option ``:show-testcases:`` is neither ``all`` nor ``not-passed``.
195 :raises SphinxExtensionError: If option ``:reportid:`` is missing.
196 :raises ReportExtensionError: If ``pyTooling_Unittest_Testsuites`` has no entry for the report ID.
197 """
198 cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*")
199 showTestcases = self._ParseStringOption("show-testcases", "all", r"all|not-passed")
200
201 self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
202 self._reportID = self._ParseStringOption("reportid")
203 self._testsuiteSummaryName = self._ParseStringOption("testsuite-summary-name", "", r".+")
204 self._showTestcases = ShowTestcases[showTestcases.replace("-", "_")]
205 self._noAssertions = "no-assertions" in self.options
206 self._hideTestsuiteSummary = "hide-testsuite-summary" in self.options
207
208 try:
209 testSummary = self._testSummaries[self._reportID]
210 except KeyError as ex:
211 raise ReportExtensionError(f"No unit testing configuration item for '{self._reportID}'.") from ex
212 self._xmlReport = testSummary["xml_report"]
213
214 @classmethod
215 def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None:
216 """
217 Check the configuration value and load the report configurations.
218
219 :param sphinxApplication: Sphinx application instance.
220 :param sphinxConfiguration: Sphinx configuration instance.
221 """
222 cls._CheckConfiguration(sphinxConfiguration)
223
224 @classmethod
225 def ReadReports(cls, sphinxApplication: Sphinx) -> None:
226 """
227 Read every configured unit test report, once per build.
228
229 A report that can't be read keeps the exception, which a directive or a report's pages show when they need it.
230
231 :param sphinxApplication: Sphinx application instance.
232 """
233 getLogger(__name__).info("[REPORT] Reading unittest reports ...")
234
235 cls._reports = {}
236 cls._readErrors = {}
237 for reportID, testSummary in cls._testSummaries.items():
238 try:
239 cls._reports[reportID] = cls._ReadReport(testSummary["xml_report"])
240 except Exception as ex:
241 cls._readErrors[reportID] = ex
242
243 @classmethod
244 def GetReport(cls, reportID: str) -> TestsuiteSummary:
245 """
246 Return a report read by :meth:`ReadReports`.
247
248 :param reportID: Identifier of the report.
249 :returns: The report's testsuite summary.
250 :raises ReportExtensionError: If the report wasn't read, chained to the exception reading it raised.
251 """
252 try:
253 return cls._reports[reportID]
254 except KeyError:
255 cause = cls._readErrors.get(reportID, None)
256 raise ReportExtensionError(f"Unittest report '{reportID}' wasn't read.") from cause
257
258 @staticmethod
259 def _ReadReport(xmlReport: Path) -> TestsuiteSummary:
260 """
261 Read a report file, choosing the reader by the file's root element.
262
263 :param xmlReport: The report file.
264 :returns: The report's testsuite summary, aggregated.
265 :raises ReportExtensionError: If the file's root element is no known report format.
266 :raises ReportsPackageMissingError: If pyEDAA.Reports, or its reader for the report's format, isn't installed.
267 """
268 _, rootElement = next(iterparse(xmlReport, events=("start",))) # nosec B314 - the project's own report
269 try:
270 reportFormat = REPORT_FORMATS[rootElement.tag]
271 except KeyError:
272 ex = ReportExtensionError(f"Unittest report '{xmlReport}' has an unknown format.")
273 ex.add_note(f"Got root element '<{rootElement.tag}>'; supported: {', '.join(REPORT_FORMATS)}")
274 raise ex from None
275
276 if reportFormat == "pyTooling":
277 try:
278 from pyEDAA.Reports.Unittesting.pyTooling import Document as pyToolingDocument
279 except ImportError as cause:
280 raise ReportsPackageMissingError("Reading a pyTooling test report") from cause
281
282 return pyToolingDocument(xmlReport, analyzeAndConvert=True)
283
284 try:
285 from pyEDAA.Reports.Unittesting.JUnit import Document as JUnitDocument
286 except ImportError as cause:
287 raise ReportsPackageMissingError("Reading a unit test report") from cause
288
289 document = JUnitDocument(xmlReport, analyzeAndConvert=True)
290 document.Aggregate()
291 testsuiteSummary = document.ToTestsuiteSummary()
292 testsuiteSummary.Aggregate()
293
294 return testsuiteSummary
295
296 @classmethod
297 def _CheckConfiguration(cls, sphinxConfiguration: Config) -> None:
298 """
299 Check and load the report configurations from ``pyTooling_Unittest_Testsuites``.
300
301 :param sphinxConfiguration: Sphinx configuration instance.
302 :raises ReportExtensionError: If the configuration value isn't registered.
303 :raises ReportExtensionError: If a report configuration has no ``xml_report``.
304 :raises ReportExtensionError: If the ``xml_report`` file doesn't exist.
305 :raises ReportExtensionError: If ``pages`` isn't a string.
306 :raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
307 Use a name like 'unittests/src', separated by '/', without '.' or '..'.
308 """
309 variableName = f"{CONFIG_PREFIX}_Testsuites"
310 cls._testSummaries = {}
311
312 try:
313 allTestsuites: dict[str, TestsuiteConfiguration] = sphinxConfiguration[variableName]
314 except (KeyError, AttributeError) as ex:
315 raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
316
317 for reportID, testSummary in allTestsuites.items():
318 summaryName = f"conf.py: {variableName}:[{reportID}]"
319
320 try:
321 xmlReport = Path(testSummary["xml_report"])
322 except KeyError as ex:
323 raise ReportExtensionError(f"{summaryName}.xml_report: Configuration is missing.") from ex
324
325 if not xmlReport.exists():
326 raise ReportExtensionError(
327 f"{summaryName}.xml_report: Unittest report file '{xmlReport}' doesn't exist."
328 ) from FileNotFoundError(xmlReport)
329
330 if (pages := testSummary.get("pages", None)) is not None:
331 pages = ReportPages.CheckPagesConfiguration(summaryName, pages)
332
333 cls._testSummaries[reportID] = {
334 "xml_report": xmlReport,
335 "pages": pages
336 }
337
338 def _SortedValues(self, d: Mapping[str, Any]) -> Generator[Any, None, None]:
339 """
340 Yield the values of a mapping, sorted by key.
341
342 :param d: The mapping, e.g. a testsuite's testcases by name.
343 :returns: A generator of the values, sorted by their keys.
344 """
345 for key in sorted(d.keys()):
346 yield d[key]
347
348 @staticmethod
349 def _ConvertTestcaseStatusToSymbol(status: TestcaseStatus) -> str:
350 """
351 Return the symbol shown for a testcase's status.
352
353 :param status: The testcase's status.
354 :returns: An emoji, e.g. ✅ for a passed testcase.
355 """
356 from pyEDAA.Reports.Unittesting import TestcaseStatus
357
358 if status is TestcaseStatus.Passed or status is TestcaseStatus.ExpectedFailed:
359 return "✅"
360 elif status is TestcaseStatus.Failed:
361 return "❌"
362 elif status is TestcaseStatus.Skipped:
363 return "⚠️"
364 elif status is TestcaseStatus.Aborted:
365 return "🚫"
366 elif status is TestcaseStatus.Excluded:
367 return "➖"
368 elif status is TestcaseStatus.Errored:
369 return "❗"
370 elif status is TestcaseStatus.SetupError:
371 return "⛔"
372 elif status is TestcaseStatus.Unknown:
373 return "❓"
374 else:
375 return "❌"
376
377 @staticmethod
378 def _ConvertTestsuiteStatusToSymbol(status: TestsuiteStatus) -> str:
379 """
380 Return the symbol shown for a testsuite's status.
381
382 :param status: The testsuite's status.
383 :returns: An emoji, e.g. ✅ for a passed testsuite.
384 """
385 from pyEDAA.Reports.Unittesting import TestsuiteStatus
386
387 if status is TestsuiteStatus.Passed:
388 return "✅"
389 elif status is TestsuiteStatus.Failed:
390 return "❌"
391 elif status is TestsuiteStatus.Skipped:
392 return "⚠️"
393 elif status is TestsuiteStatus.Aborted:
394 return "🚫"
395 elif status is TestsuiteStatus.Excluded:
396 return "➖"
397 elif status is TestsuiteStatus.Errored:
398 return "❗"
399 elif status is TestsuiteStatus.SetupError:
400 return "⛔"
401 elif status is TestsuiteStatus.Unknown:
402 return "❓"
403 else:
404 return "❌"
405
406 @staticmethod
407 def _FormatTimedelta(delta: Nullable[timedelta]) -> str:
408 """
409 Format a duration as ``HH:MM:SS.sss``, rounded to milliseconds.
410
411 :param delta: The duration, or ``None``.
412 :returns: The formatted duration, or ``""`` for ``None``.
413 """
414 if delta is None:
415 return ""
416
417 # Compute by hand, because timedelta._to_microseconds is not officially documented
418 microseconds = (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
419 milliseconds = (microseconds + 500) // 1000
420 seconds = milliseconds // 1000
421 minutes = seconds // 60
422 hours = minutes // 60
423 return f"{hours:02}:{minutes % 60:02}:{seconds % 60:02}.{milliseconds % 1000:03}"
424
425 def _GenerateTestSummaryTable(self) -> nodes.table:
426 """
427 Build the table: a row per testsuite and listed testcase, and a summary row.
428
429 :returns: The table.
430 """
431 # Create a table and table header with 8 columns
432 columns: list[tuple[str, Nullable[int]]] = [
433 ("Testsuite / Testcase", 6),
434 ("Testcases", 1),
435 ("Skipped", 1),
436 ("Errored", 1),
437 ("Failed", 1),
438 ("Passed", 1),
439 ("Assertions", 1),
440 ("Runtime (HH:MM:SS.sss)", 2),
441 ]
442
443 # If assertions shouldn't be displayed, remove column from columns list
444 if self._noAssertions:
445 columns.pop(6)
446
447 cssClasses = ["report-unittest-table", f"report-unittest-{self._reportID}"]
448 cssClasses.extend(self._cssClasses)
449
450 tableGroup = self._CreateSingleRowTableHeader(
451 identifier=self._reportID,
452 columns=columns,
453 classes=cssClasses
454 )
455 tableBody = nodes.tbody()
456 tableGroup += tableBody
457
458 self._RenderRoot(tableBody, self._testsuite, not self._hideTestsuiteSummary, self._testsuiteSummaryName)
459
460 return tableGroup.parent
461
462 def _RenderRoot(
463 self,
464 tableBody: nodes.tbody,
465 testsuiteSummary: TestsuiteSummary,
466 includeRoot: bool = True,
467 testsuiteSummaryName: Nullable[str] = None
468 ) -> None:
469 """
470 Add the rows of a testsuite summary: optionally its own row, its testsuites' rows, and the summary row.
471
472 :param tableBody: The table body the rows are added to.
473 :param testsuiteSummary: The testsuite summary.
474 :param includeRoot: Optional, whether the testsuite summary gets a row of its own. Default: ``True``.
475 :param testsuiteSummaryName: Optional, name replacing the testsuite summary's name; ``""`` keeps it.
476 Default: ``None``.
477 """
478 level = 0
479
480 if includeRoot:
481 level += 1
482 state = self._ConvertTestsuiteStatusToSymbol(testsuiteSummary._status)
483
484 tableRow = nodes.row(
485 "", classes=["report-testsuitesummary", f"testsuitesummary-{testsuiteSummary._status.name.lower()}"]
486 )
487 tableBody += tableRow
488
489 name = testsuiteSummary.Name if testsuiteSummaryName == "" else testsuiteSummaryName
490 tableRow += nodes.entry("", nodes.Text(f"{state}{name}"))
491 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.TestcaseCount}"))
492 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Skipped}"))
493 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Errored}"))
494 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Failed}"))
495 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Passed}"))
496 if not self._noAssertions:
497 tableRow += nodes.entry("", nodes.Text(""))
498 tableRow += nodes.entry("", nodes.Text(f"{self._FormatTimedelta(testsuiteSummary.TotalDuration)}"))
499
500 for ts in self._SortedValues(testsuiteSummary._testsuites):
501 self._RenderTestsuite(tableBody, ts, level)
502
503 self._RenderSummary(tableBody, testsuiteSummary)
504
505 def _RenderTestsuite(self, tableBody: nodes.tbody, testsuite: Testsuite, level: int) -> None:
506 """
507 Add a row for a testsuite, then - recursively - rows for its testsuites and its listed testcases.
508
509 :param tableBody: The table body the rows are added to.
510 :param testsuite: The testsuite.
511 :param level: The testsuite's depth in the hierarchy, shown by indentation.
512 """
513 state = self._ConvertTestsuiteStatusToSymbol(testsuite._status)
514
515 tableRow = nodes.row("", classes=["report-testsuite", f"testsuite-{testsuite._status.name.lower()}"])
516 tableBody += tableRow
517
518 tableRow += self._NameEntry(2 * level, state, testsuite, "ts")
519 tableRow += nodes.entry("", nodes.Text(f"{testsuite.TestcaseCount}"))
520 tableRow += nodes.entry("", nodes.Text(f"{testsuite.Skipped}"))
521 tableRow += nodes.entry("", nodes.Text(f"{testsuite.Errored}"))
522 tableRow += nodes.entry("", nodes.Text(f"{testsuite.Failed}"))
523 tableRow += nodes.entry("", nodes.Text(f"{testsuite.Passed}"))
524 if not self._noAssertions:
525 tableRow += nodes.entry("", nodes.Text(""))
526 tableRow += nodes.entry("", nodes.Text(f"{self._FormatTimedelta(testsuite.TotalDuration)}"))
527
528 for ts in self._SortedValues(testsuite._testsuites):
529 self._RenderTestsuite(tableBody, ts, level + 1)
530
531 for testcase in self._SortedValues(testsuite._testcases):
532 if testcase._status == self._showTestcases:
533 self._RenderTestcase(tableBody, testcase, level + 1)
534
535 def _RenderTestcase(self, tableBody: nodes.tbody, testcase: Testcase, level: int) -> None:
536 """
537 Add a row for a testcase.
538
539 :param tableBody: The table body the row is added to.
540 :param testcase: The testcase.
541 :param level: The testcase's depth in the hierarchy, shown by indentation.
542 """
543 state = self._ConvertTestcaseStatusToSymbol(testcase._status)
544
545 tableRow = nodes.row("", classes=["report-testcase", f"testcase-{testcase._status.name.lower()}"])
546 tableBody += tableRow
547
548 tableRow += self._NameEntry(2 * level, state, testcase, "tc")
549 tableRow += nodes.entry("", nodes.Text(""))
550 tableRow += nodes.entry("", nodes.Text(""))
551 tableRow += nodes.entry("", nodes.Text(""))
552 tableRow += nodes.entry("", nodes.Text(""))
553 tableRow += nodes.entry("", nodes.Text(""))
554 if not self._noAssertions:
555 tableRow += nodes.entry("", nodes.Text(f"{testcase.AssertionCount}"))
556 tableRow += nodes.entry("", nodes.Text(f"{self._FormatTimedelta(testcase.TotalDuration)}"))
557
558 def _NameEntry(self, level: int, state: str, entity: Testsuite | Testcase, roleName: str) -> nodes.entry:
559 """
560 Create the cell naming a testsuite or testcase, indented, and linked to its page if the report has pages.
561
562 :param level: The number of indentation steps.
563 :param state: The status symbol written before the name.
564 :param entity: The testsuite or testcase.
565 :param roleName: The role referring to it: ``ts`` for a testsuite, ``tc`` for a testcase.
566 :returns: The table cell.
567 """
568 from pyTooling.Sphinx.UnittestPages import UnittestReportPages
569
570 if UnittestReportPages.HasPages(self._reportID):
571 pages = UnittestReportPages.GetPages(self._reportID)
572 if (entry := pages.Entry(entity)) is not None:
573 # a reference has to be inside a text element; an inline keeps the cell free of a paragraph, as the
574 # others are
575 reference = pages.CreateReference(roleName, entry, self.env.docname)
576 return nodes.entry("", ReportIndentation("", "", nodes.Text(state), reference, level=level))
577
578 return nodes.entry("", ReportIndentation("", f"{state}{entity.Name}", level=level))
579
580 def _RenderSummary(self, tableBody: nodes.tbody, testsuiteSummary: TestsuiteSummary) -> None:
581 """
582 Add the summary row: the overall status and counts.
583
584 :param tableBody: The table body the row is added to.
585 :param testsuiteSummary: The testsuite summary.
586 """
587 state = self._ConvertTestsuiteStatusToSymbol(testsuiteSummary._status)
588
589 tableRow = nodes.row(
590 "", classes=["report-summary", f"testsuitesummary-{testsuiteSummary._status.name.lower()}"]
591 )
592 tableBody += tableRow
593
594 tableRow += nodes.entry("", nodes.Text(f"{state} {testsuiteSummary.Status.name.upper()}"))
595 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.TestcaseCount}"))
596 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Skipped}"))
597 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Errored}"))
598 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Failed}"))
599 tableRow += nodes.entry("", nodes.Text(f"{testsuiteSummary.Passed}"))
600 if not self._noAssertions:
601 tableRow += nodes.entry("", nodes.Text(""))
602 tableRow += nodes.entry("", nodes.Text(f"{self._FormatTimedelta(testsuiteSummary.TotalDuration)}"))
603
604 def run(self) -> list[nodes.Node]:
605 """
606 Read the configured report and return it as a table, on landscape pages in LaTeX.
607
608 A report with pages adds a hidden table of contents of its top-level testsuites, so the pages are below this
609 document in the navigation.
610
611 :returns: A :class:`~pyTooling.Sphinx.Node.Landscape` container holding the table, or the error message; then
612 the table of contents, if the report has pages.
613 """
614 container = Landscape()
615
616 try:
617 self._CheckOptions()
618 except SphinxExtensionError as ex:
619 message = f"Caught {ex.__class__.__name__} when checking options for directive '{self.directiveName}'."
620 return self._internalError(container, __name__, message, ex)
621
622 if (readError := self._readErrors.get(self._reportID, None)) is not None:
623 if isinstance(readError, ReportsPackageMissingError):
624 message = f"Caught {readError.__class__.__name__} when reading '{self._xmlReport}'."
625 else:
626 message = f"Caught {readError.__class__.__name__} when reading and parsing '{self._xmlReport}'."
627 return self._internalError(container, __name__, message, readError)
628
629 try:
630 self._testsuite = self.GetReport(self._reportID)
631 container += self._GenerateTestSummaryTable()
632 except Exception as ex:
633 message = (
634 f"Caught {ex.__class__.__name__} when generating the document structure for unittest report "
635 f"'{self._xmlReport}'."
636 )
637 return self._internalError(container, __name__, message, ex)
638
639 from pyTooling.Sphinx.UnittestPages import UnittestReportPages
640
641 if not UnittestReportPages.HasPages(self._reportID):
642 return [container]
643
644 return [container, UnittestReportPages.GetPages(self._reportID).TableOfContents(self.env.docname)]