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:

Sphinx

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)]