Unittest.py

Summary

Lines:

399 of 416 covered (95.9%)

Excluded lines:

2

Branches:

126 of 134 taken (94.0%), 8 lines partially

Coverage:

95.5%

Directory:

Sphinx

Missed lines:

300, 302-304, 307, 477-478, 485-486, 515, 561, 590, 824-829, 966-967

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
52The table's columns after the first are :class:`Column` and :class:`ColumnGroup` instances. A directive of another kind
53of test report - e.g. a simulation framework's build summary - derives from :class:`UnittestSummaryBase`, which renders
54the rows, and declares its own columns.
55
56.. seealso::
57
58   :ref:`DIR/UnittestSummary`
59      |rarr| The directive's options and configuration, with examples.
60"""
61from __future__                         import annotations
62
63from datetime                           import timedelta
64from enum                               import Flag
65from pathlib                            import Path
66from typing                             import TYPE_CHECKING, Any, Callable, ClassVar, Generator, Iterable, Mapping, \
67                                               Optional as Nullable, TypedDict
68from xml.etree.ElementTree              import iterparse  # nosec B405 - reads the root element of the project's report
69
70from docutils                           import nodes
71from docutils.parsers.rst.directives    import flag
72from sphinx.application                 import Sphinx
73from sphinx.config                      import Config
74from sphinx.util.logging                import getLogger
75
76from pyTooling.Common                   import getFullyQualifiedName, getTypeName
77from pyTooling.Decorators               import export, notimplemented, readonly
78from pyTooling.MetaClasses              import ExtendedType
79
80from pyTooling.Sphinx                   import BaseDirective, ReportExtensionError, ReportsPackageMissingError, \
81                                               SphinxExtensionError, strip, stripAndNormalize
82from pyTooling.Sphinx.Node              import Landscape, ReportIndentation
83from pyTooling.Sphinx.Pages             import ReportPages
84from pyTooling.Sphinx.Tree              import LegendPosition
85
86if TYPE_CHECKING:  # pragma: no cover
87	# pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
88	from pyEDAA.Reports.Unittesting       import TestcaseStatus, TestsuiteStatus, Testcase, Testsuite, TestsuiteBase, \
89	                                             TestsuiteSummary
90
91
92__all__ = ["CONFIG_PREFIX", "REPORT_FORMATS"]
93
94CONFIG_PREFIX = "pyTooling_Unittest"
95"""Prefix every configuration value of the unit test directive carries in :file:`conf.py`."""
96
97REPORT_FORMATS = {
98	"testsuites": "JUnit",
99	"testsuite":  "JUnit",
100	"TestReport": "pyTooling",
101}
102"""The report formats, by the root element of a report file."""
103
104
105@export
106class TestsuiteConfiguration(TypedDict):
107	"""An entry of ``pyTooling_Unittest_Testsuites``, after :meth:`UnittestSummary.CheckConfiguration` read it."""
108
109	xml_report: Path           #: The unit test report, in JUnit XML or pyTooling's XML format.
110	pages:      Nullable[str]  #: Document name the pages per testsuite and testcase are generated below, if any.
111
112
113@export
114class ShowTestcases(Flag):
115	"""
116	Which testcases a unit test summary lists, by their status.
117
118	A member compares equal to a :class:`~pyEDAA.Reports.Unittesting.TestcaseStatus` it includes.
119	"""
120
121	passed =    1  #: Passed testcases, and testcases failing as expected.
122	failed =    2  #: Failed testcases, and testcases passing unexpectedly.
123	skipped =   4  #: Skipped testcases.
124	excluded =  8  #: Excluded testcases.
125	errors =   16  #: Errored testcases, and testcases whose setup failed.
126	aborted =  32  #: Aborted testcases.
127
128	all = passed | failed | skipped | excluded | errors | aborted  #: Every testcase.
129	not_passed = all & ~passed                                     #: Every testcase but the passed ones.
130
131	def __eq__(self, other: Any) -> bool:
132		"""
133		Check whether a testcase status is included.
134
135		:param other: A testcase status.
136		:returns:     ``True`` if ``other`` is a :class:`~pyEDAA.Reports.Unittesting.TestcaseStatus` this member includes.
137		"""
138		from pyEDAA.Reports.Unittesting import TestcaseStatus
139
140		if isinstance(other, TestcaseStatus):
141			if other is TestcaseStatus.Passed or other is TestcaseStatus.ExpectedFailed:
142				return ShowTestcases.passed in self
143			elif other is TestcaseStatus.Failed or other is TestcaseStatus.UnexpectedPassed:
144				return ShowTestcases.failed in self
145			elif other is TestcaseStatus.Skipped:
146				return ShowTestcases.skipped in self
147			elif other is TestcaseStatus.Excluded:
148				return ShowTestcases.excluded in self
149			elif other is TestcaseStatus.Errored or other is TestcaseStatus.SetupError:
150				return ShowTestcases.errors in self
151			elif other is TestcaseStatus.Aborted:
152				return ShowTestcases.aborted in self
153
154		return False
155
156
157@export
158class Column(metaclass=ExtendedType, slots=True):
159	"""
160	A column of a unit test summary table: its title, its width, and the cells it adds to the rows.
161
162	A row shows a testsuite summary, a testsuite or a testcase. A column names a function per kind of row, which returns
163	the cell's content: a text, a node, or the cell itself - an :class:`~docutils.nodes.entry`, e.g. to give it a CSS
164	class. A column without a function for a kind of row leaves the cell of such a row empty.
165	"""
166
167	_title:     str                                                         #: Title of the column.
168	_width:     Nullable[int]                                               #: Relative width, or ``None``.
169	_testsuite: Nullable[Callable[[TestsuiteBase[Any]], str | nodes.Node]]  #: Content of a testsuite('s summary) row.
170	_testcase:  Nullable[Callable[[Testcase], str | nodes.Node]]            #: Content of a testcase's row.
171
172	def __init__(
173		self,
174		title: str,
175		width: Nullable[int] = None,
176		*,
177		testsuite: Nullable[Callable[[TestsuiteBase[Any]], str | nodes.Node]] = None,
178		testcase: Nullable[Callable[[Testcase], str | nodes.Node]] = None
179	) -> None:
180		"""
181		Initializes a column of a unit test summary table.
182
183		:param title:       Title of the column.
184		:param width:       Optional, relative width of the column; ``None`` leaves it to the writer. Default: ``None``.
185		:param testsuite:   Optional, function returning the content of the cell of a testsuite's row and of a testsuite
186		                    summary's rows. Default: ``None``.
187		:param testcase:    Optional, function returning the content of the cell of a testcase's row. Default: ``None``.
188		:raises ValueError: If parameter 'title' is None.
189		:raises TypeError:  If parameter 'title' is not of type :class:`str`.
190		:raises TypeError:  If parameter 'width' is not of type :class:`int`.
191		:raises TypeError:  If parameter 'testsuite' is not callable.
192		:raises TypeError:  If parameter 'testcase' is not callable.
193		"""
194		if title is None:
195			raise ValueError("Parameter 'title' is None.")
196		elif not isinstance(title, str):
197			ex = TypeError("Parameter 'title' is not of type 'str'.")
198			ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.")
199			raise ex
200
201		if width is not None and not isinstance(width, int):
202			ex = TypeError("Parameter 'width' is not of type 'int'.")
203			ex.add_note(f"Got type '{getFullyQualifiedName(width)}'.")
204			raise ex
205
206		for parameterName, function in (
207			("testsuite", testsuite),
208			("testcase",  testcase)
209		):
210			if function is not None and not callable(function):
211				ex = TypeError(f"Parameter '{parameterName}' is not callable.")
212				ex.add_note(f"Got type '{getFullyQualifiedName(function)}'.")
213				raise ex
214
215		self._title =     title
216		self._width =     width
217		self._testsuite = testsuite
218		self._testcase =  testcase
219
220	@readonly
221	def Title(self) -> str:
222		"""
223		Read-only property to access the column's title (:attr:`_title`).
224
225		:returns: The title.
226		"""
227		return self._title
228
229	@readonly
230	def Width(self) -> Nullable[int]:
231		"""
232		Read-only property to access the column's relative width (:attr:`_width`).
233
234		:returns: The width, or ``None`` if the writer chooses it.
235		"""
236		return self._width
237
238	def TestsuiteCell(self, testsuite: TestsuiteBase[Any]) -> nodes.entry:
239		"""
240		Create the column's cell of a testsuite's row, or of a testsuite summary's row.
241
242		:param testsuite: The testsuite or testsuite summary.
243		:returns:         The cell.
244		"""
245		return self._Cell(self._testsuite, testsuite)
246
247	def TestcaseCell(self, testcase: Testcase) -> nodes.entry:
248		"""
249		Create the column's cell of a testcase's row.
250
251		:param testcase: The testcase.
252		:returns:        The cell.
253		"""
254		return self._Cell(self._testcase, testcase)
255
256	@staticmethod
257	def _Cell(function: Nullable[Callable[[Any], str | nodes.Node]], entity: Any) -> nodes.entry:
258		"""
259		Create a cell from the content a function returns for a row's testsuite or testcase.
260
261		:param function: The function, or ``None`` for an empty cell.
262		:param entity:   The row's testsuite, testsuite summary or testcase.
263		:returns:        The cell.
264		"""
265		if function is None:
266			return nodes.entry("", nodes.Text(""))
267
268		content = function(entity)
269		if isinstance(content, nodes.entry):
270			return content
271		elif isinstance(content, str):
272			return nodes.entry("", nodes.Text(content))
273
274		return nodes.entry("", content)
275
276
277@export
278class ColumnGroup(metaclass=ExtendedType, slots=True):
279	"""
280	A group of columns of a unit test summary table, under a common title: the table's header gets a second row.
281	"""
282
283	_title:   str                 #: Title of the group.
284	_columns: tuple[Column, ...]  #: The grouped columns.
285
286	def __init__(self, title: str, columns: Iterable[Column]) -> None:
287		"""
288		Initializes a group of columns.
289
290		:param title:       Title of the group.
291		:param columns:     The grouped columns.
292		:raises ValueError: If parameter 'title' is None.
293		:raises TypeError:  If parameter 'title' is not of type :class:`str`.
294		:raises ValueError: If parameter 'columns' is None.
295		:raises TypeError:  If parameter 'columns' is not iterable.
296		:raises TypeError:  If parameter 'columns' contains an element not of type :class:`Column`.
297		:raises ValueError: If parameter 'columns' is empty.
298		"""
299		if title is None:
300			raise ValueError("Parameter 'title' is None.")
301		elif not isinstance(title, str):
302			ex = TypeError("Parameter 'title' is not of type 'str'.")
303			ex.add_note(f"Got type '{getFullyQualifiedName(title)}'.")
304			raise ex
305
306		if columns is None:
307			raise ValueError("Parameter 'columns' is None.")
308		elif not isinstance(columns, Iterable):
309			ex = TypeError("Parameter 'columns' is not iterable.")
310			ex.add_note(f"Got type '{getFullyQualifiedName(columns)}'.")
311			raise ex
312
313		columnList = []
314		for column in columns:
315			if not isinstance(column, Column):
316				ex = TypeError("Parameter 'columns' contains an element not of type 'Column'.")
317				ex.add_note(f"Got type '{getFullyQualifiedName(column)}'.")
318				raise ex
319
320			columnList.append(column)
321
322		if len(columnList) == 0:
323			raise ValueError("Parameter 'columns' is empty.")
324
325		self._title =   title
326		self._columns = tuple(columnList)
327
328	@readonly
329	def Title(self) -> str:
330		"""
331		Read-only property to access the group's title (:attr:`_title`).
332
333		:returns: The title.
334		"""
335		return self._title
336
337	@readonly
338	def Columns(self) -> tuple[Column, ...]:
339		"""
340		Read-only property to access the grouped columns (:attr:`_columns`).
341
342		:returns: The columns, in their order in the table.
343		"""
344		return self._columns
345
346
347@export
348class UnittestSummaryBase(BaseDirective):
349	"""
350	Base-class of a directive rendering a testsuite summary - unit test results - as a table, per testsuite and testcase.
351
352	The table has a row per testsuite and listed testcase, indented by its depth, optionally a row of the testsuite
353	summary above them, and a row of the overall status below them. Its first column names the row - linked to the
354	row's page, if the report has pages -, the other columns are the :class:`Column` and :class:`ColumnGroup` instances
355	:meth:`_Columns` returns.
356
357	The report files are declared in a configuration value of :file:`conf.py` (:attr:`_CONFIG_VALUE`), each with an
358	identifier, the report file (key :attr:`_REPORT_KEY`) and optionally the key ``pages``. A derived class names them,
359	declares its own class variables :attr:`_testSummaries`, :attr:`_reports` and :attr:`_readErrors`, reads a report
360	file by :meth:`_ReadReport`, reads its options in :meth:`_CheckOptions`, and returns its pages by :meth:`_Pages`.
361
362	A legend of the status symbols (:attr:`_LEGEND`) is drawn below the table, or above it or not at all, as option
363	``:legend:`` chooses; a derived class accepts the option by adding ``"legend": LegendPosition.Parse`` to its
364	``option_spec``.
365	"""
366
367	_CONFIG_VALUE: ClassVar[str]                                         #: Configuration value declaring the reports.
368	_REPORT_KEY:   ClassVar[str]                                         #: Key of a report's file, e.g. ``xml_report``.
369	_REPORT_KIND:  ClassVar[str]                                         #: Kind of report, e.g. ``Unittest report``.
370	_NAME_COLUMN:  ClassVar[Column] = Column("Testsuite / Testcase", 6)  #: The first column, naming the rows.
371
372	_testSummaries: ClassVar[dict[str, dict[str, Any]]]    #: Report configurations, by report ID.
373	_reports:       ClassVar[dict[str, TestsuiteSummary]]  #: Reports read by :meth:`ReadReports`, by ID.
374	_readErrors:    ClassVar[dict[str, Exception]]         #: Why a report couldn't be read, by report ID.
375
376	_LEGEND: ClassVar[tuple[tuple[str, str], ...]] = (
377		("✅", "passed"),
378		("❌", "failed"),
379		("⚠️", "skipped"),
380		("➖", "excluded"),
381		("❗", "errored"),
382		("⛔", "setup error"),
383		("🚫", "aborted"),
384		("❓", "unknown")
385	)  #: The status symbols of testsuites and testcases with the word explaining each, in the order of the legend.
386
387	_cssClasses:           list[str]         #: Additional CSS classes of the table.
388	_reportID:             str               #: Identifier of the report in the configuration.
389	_reportFile:           Path              #: The report file.
390	_hideTestsuiteSummary: bool              #: Whether the testsuite summary's row is left out.
391	_testsuiteSummaryName: str               #: Name replacing the testsuite summary's name, or ``""``.
392	_showTestcases:        ShowTestcases     #: Which testcases are listed.
393	_legend:               LegendPosition    #: Where the legend of the status symbols is drawn, by option ``:legend:``.
394	_testsuite:            TestsuiteSummary  #: The report's testsuite summary.
395
396	@notimplemented("A derived directive implements '_CheckOptions'.")
397	def _CheckOptions(self) -> None:
398		"""
399		Parse all directive options or use default values.
400
401		A derived class sets :attr:`_cssClasses`, :attr:`_reportID`, :attr:`_reportFile`, :attr:`_hideTestsuiteSummary`,
402		:attr:`_testsuiteSummaryName` and :attr:`_showTestcases`.
403		"""
404
405	@classmethod
406	def CheckConfiguration(cls, sphinxApplication: Sphinx, sphinxConfiguration: Config) -> None:
407		"""
408		Check the configuration value and load the report configurations.
409
410		:param sphinxApplication:   Sphinx application instance.
411		:param sphinxConfiguration: Sphinx configuration instance.
412		"""
413		cls._CheckConfiguration(sphinxConfiguration)
414
415	@classmethod
416	def ReadReports(cls, sphinxApplication: Sphinx) -> None:
417		"""
418		Read every configured report, once per build.
419
420		A report that can't be read keeps the exception, which a directive or a report's pages show when they need it.
421
422		:param sphinxApplication: Sphinx application instance.
423		"""
424		getLogger(__name__).info(f"[REPORT] Reading {cls._REPORT_KIND.lower()}s ...")
425
426		cls._reports =    {}
427		cls._readErrors = {}
428		for reportID, testSummary in cls._testSummaries.items():
429			try:
430				cls._reports[reportID] = cls._ReadReport(testSummary[cls._REPORT_KEY])
431			except Exception as ex:
432				cls._readErrors[reportID] = ex
433
434	@classmethod
435	def GetReport(cls, reportID: str) -> TestsuiteSummary:
436		"""
437		Return a report read by :meth:`ReadReports`.
438
439		:param reportID:              Identifier of the report.
440		:returns:                     The report's testsuite summary.
441		:raises ReportExtensionError: If the report wasn't read, chained to the exception reading it raised.
442		"""
443		try:
444			return cls._reports[reportID]
445		except KeyError:
446			cause = cls._readErrors.get(reportID, None)
447			raise ReportExtensionError(f"{cls._REPORT_KIND} '{reportID}' wasn't read.") from cause
448
449	@classmethod
450	@notimplemented("A derived directive implements '_ReadReport'.")
451	def _ReadReport(cls, reportFile: Path) -> TestsuiteSummary:
452		"""
453		Read a report file.
454
455		:param reportFile: The report file.
456		:returns:          The report's testsuite summary, aggregated.
457		"""
458
459	@classmethod
460	def _CheckConfiguration(cls, sphinxConfiguration: Config) -> None:
461		"""
462		Check and load the report configurations from the configuration value :attr:`_CONFIG_VALUE`.
463
464		:param sphinxConfiguration:   Sphinx configuration instance.
465		:raises ReportExtensionError: If the configuration value isn't registered.
466		:raises ReportExtensionError: If a report configuration has no report file (key :attr:`_REPORT_KEY`).
467		:raises ReportExtensionError: If the report file doesn't exist.
468		:raises ReportExtensionError: If ``pages`` isn't a string.
469		:raises ReportExtensionError: If ``pages`` isn't a relative document name. |br|
470		                              Use a name like 'unittests/src', separated by '/', without '.' or '..'.
471		"""
472		variableName = cls._CONFIG_VALUE
473		cls._testSummaries = {}
474
475		try:
476			allTestsuites: dict[str, dict[str, Any]] = sphinxConfiguration[variableName]
477		except (KeyError, AttributeError) as ex:
478			raise ReportExtensionError(f"Configuration option '{variableName}' is not configured.") from ex
479
480		for reportID, testSummary in allTestsuites.items():
481			summaryName = f"conf.py: {variableName}:[{reportID}]"
482
483			try:
484				reportFile = Path(testSummary[cls._REPORT_KEY])
485			except KeyError as ex:
486				raise ReportExtensionError(f"{summaryName}.{cls._REPORT_KEY}: Configuration is missing.") from ex
487
488			if not reportFile.exists():
489				raise ReportExtensionError(
490					f"{summaryName}.{cls._REPORT_KEY}: {cls._REPORT_KIND} file '{reportFile}' doesn't exist."
491				) from FileNotFoundError(reportFile)
492
493			if (pages := testSummary.get("pages", None)) is not None:
494				pages = ReportPages.CheckPagesConfiguration(summaryName, pages)
495
496			cls._testSummaries[reportID] = {
497				cls._REPORT_KEY: reportFile,
498				"pages":         pages
499			}
500
501	@notimplemented("A derived directive implements '_Columns'.")
502	def _Columns(self) -> list[Column | ColumnGroup]:
503		"""
504		Return the table's columns after the first one, which names the rows.
505
506		:returns: The columns and groups of columns, in their order in the table.
507		"""
508
509	def _Pages(self) -> Nullable[ReportPages[Any]]:
510		"""
511		Return the pages of the report, which the first column links to.
512
513		:returns: The report's pages, or ``None`` if it has none.
514		"""
515		return None
516
517	def _TableClasses(self) -> list[str]:
518		"""
519		Return the CSS classes of the table: ``report-unittest-table``, ``report-unittest-<report ID>`` and the classes of
520		option ``:class:``.
521
522		:returns: The CSS classes.
523		"""
524		return ["report-unittest-table", f"report-unittest-{self._reportID}", *self._cssClasses]
525
526	def _SortedValues(self, d: Mapping[str, Any]) -> Generator[Any, None, None]:
527		"""
528		Yield the values of a mapping, sorted by key.
529
530		:param d: The mapping, e.g. a testsuite's testcases by name.
531		:returns: A generator of the values, sorted by their keys.
532		"""
533		for key in sorted(d.keys()):
534			yield d[key]
535
536	@staticmethod
537	def _ConvertTestcaseStatusToSymbol(status: TestcaseStatus) -> str:
538		"""
539		Return the symbol shown for a testcase's status.
540
541		:param status: The testcase's status.
542		:returns:      An emoji, e.g. ✅ for a passed testcase.
543		"""
544		from pyEDAA.Reports.Unittesting import TestcaseStatus
545
546		if status is TestcaseStatus.Passed or status is TestcaseStatus.ExpectedFailed:
547			return "✅"
548		elif status is TestcaseStatus.Failed:
549			return "❌"
550		elif status is TestcaseStatus.Skipped:
551			return "⚠️"
552		elif status is TestcaseStatus.Aborted:
553			return "🚫"
554		elif status is TestcaseStatus.Excluded:
555			return "➖"
556		elif status is TestcaseStatus.Errored:
557			return "❗"
558		elif status is TestcaseStatus.SetupError:
559			return "⛔"
560		elif status is TestcaseStatus.Unknown:
561			return "❓"
562		else:
563			return "❌"
564
565	@staticmethod
566	def _ConvertTestsuiteStatusToSymbol(status: TestsuiteStatus) -> str:
567		"""
568		Return the symbol shown for a testsuite's status.
569
570		:param status: The testsuite's status.
571		:returns:      An emoji, e.g. ✅ for a passed testsuite.
572		"""
573		from pyEDAA.Reports.Unittesting import TestsuiteStatus
574
575		if status is TestsuiteStatus.Passed:
576			return "✅"
577		elif status is TestsuiteStatus.Failed:
578			return "❌"
579		elif status is TestsuiteStatus.Skipped:
580			return "⚠️"
581		elif status is TestsuiteStatus.Aborted:
582			return "🚫"
583		elif status is TestsuiteStatus.Excluded:
584			return "➖"
585		elif status is TestsuiteStatus.Errored:
586			return "❗"
587		elif status is TestsuiteStatus.SetupError:
588			return "⛔"
589		elif status is TestsuiteStatus.Unknown:
590			return "❓"
591		else:
592			return "❌"
593
594	@staticmethod
595	def _FormatTimedelta(delta: Nullable[timedelta]) -> str:
596		"""
597		Format a duration as ``HH:MM:SS.sss``, rounded to milliseconds.
598
599		:param delta: The duration, or ``None``.
600		:returns:     The formatted duration, or ``""`` for ``None``.
601		"""
602		if delta is None:
603			return ""
604
605		# Compute by hand, because timedelta._to_microseconds is not officially documented
606		microseconds = (delta.days * 86_400 + delta.seconds) * 1_000_000 + delta.microseconds
607		milliseconds = (microseconds + 500) // 1000
608		seconds = milliseconds // 1000
609		minutes = seconds // 60
610		hours = minutes // 60
611		return f"{hours:02}:{minutes % 60:02}:{seconds % 60:02}.{milliseconds % 1000:03}"
612
613	def _GenerateTestSummaryTable(self) -> nodes.table:
614		"""
615		Build the table: a header of the columns, a row per testsuite and listed testcase, and a summary row.
616
617		A header has a second row, if a column is a :class:`ColumnGroup`.
618
619		:returns: The table.
620		"""
621		columns: list[Column] = []
622		groups = False
623		headerColumns: list[tuple[str, Nullable[list[tuple[str, int]]], Nullable[int]]] = [
624			(self._NAME_COLUMN._title, None, self._NAME_COLUMN._width)
625		]
626		for column in self._Columns():
627			if isinstance(column, ColumnGroup):
628				groups = True
629				columns.extend(column._columns)
630				headerColumns.append((column._title, [(c._title, c._width) for c in column._columns], None))
631			else:
632				columns.append(column)
633				headerColumns.append((column._title, None, column._width))
634
635		if groups:
636			tableGroup = self._CreateDoubleRowTableHeader(
637				identifier=self._reportID,
638				columns=headerColumns,
639				classes=self._TableClasses()
640			)
641		else:
642			tableGroup = self._CreateSingleRowTableHeader(
643				identifier=self._reportID,
644				columns=[(title, width) for title, _, width in headerColumns],
645				classes=self._TableClasses()
646			)
647
648		tableBody = nodes.tbody()
649		tableGroup += tableBody
650
651		self._RenderRoot(tableBody, columns, self._testsuite, not self._hideTestsuiteSummary, self._testsuiteSummaryName)
652
653		return tableGroup.parent
654
655	def _CreateLegend(self) -> nodes.paragraph:
656		"""
657		Build the legend of the status symbols: a paragraph naming each symbol of :attr:`_LEGEND` with its word.
658
659		The paragraph gets the CSS classes ``report-unittest-legend`` and ``report-unittest-legend-<position>``.
660
661		:returns: The legend.
662		"""
663		classes = ["report-unittest-legend", f"report-unittest-legend-{self._legend}"]
664		legend =  nodes.paragraph("", "Legend: ", classes=classes)
665		for index, (symbol, text) in enumerate(self._LEGEND):
666			if index > 0:
667				legend += nodes.Text(", ")
668
669			legend += nodes.inline("", f"{symbol} {text}", classes=["report-unittest-legend-item"])
670
671		return legend
672
673	def _RenderRoot(
674		self,
675		tableBody: nodes.tbody,
676		columns: list[Column],
677		testsuiteSummary: TestsuiteSummary,
678		includeRoot: bool = True,
679		testsuiteSummaryName: Nullable[str] = None
680	) -> None:
681		"""
682		Add the rows of a testsuite summary: optionally its own row, its testsuites' rows, and the summary row.
683
684		:param tableBody:            The table body the rows are added to.
685		:param columns:              The columns after the first one.
686		:param testsuiteSummary:     The testsuite summary.
687		:param includeRoot:          Optional, whether the testsuite summary gets a row of its own. Default: ``True``.
688		:param testsuiteSummaryName: Optional, name replacing the testsuite summary's name; ``""`` keeps it.
689		                             Default: ``None``.
690		"""
691		level = 0
692
693		if includeRoot:
694			level += 1
695			state = self._ConvertTestsuiteStatusToSymbol(testsuiteSummary._status)
696
697			tableRow = nodes.row(
698				"", classes=["report-testsuitesummary", f"testsuitesummary-{testsuiteSummary._status.name.lower()}"]
699			)
700			tableBody += tableRow
701
702			name = testsuiteSummary.Name if testsuiteSummaryName == "" else testsuiteSummaryName
703			tableRow += nodes.entry("", nodes.Text(f"{state}{name}"))
704			for column in columns:
705				tableRow += column.TestsuiteCell(testsuiteSummary)
706
707		for ts in self._SortedValues(testsuiteSummary._testsuites):
708			self._RenderTestsuite(tableBody, columns, ts, level)
709
710		self._RenderSummary(tableBody, columns, testsuiteSummary)
711
712	def _RenderTestsuite(self, tableBody: nodes.tbody, columns: list[Column], testsuite: Testsuite, level: int) -> None:
713		"""
714		Add a row for a testsuite, then - recursively - rows for its testsuites and its listed testcases.
715
716		:param tableBody: The table body the rows are added to.
717		:param columns:   The columns after the first one.
718		:param testsuite: The testsuite.
719		:param level:     The testsuite's depth in the hierarchy, shown by indentation.
720		"""
721		state = self._ConvertTestsuiteStatusToSymbol(testsuite._status)
722
723		tableRow = nodes.row("", classes=["report-testsuite", f"testsuite-{testsuite._status.name.lower()}"])
724		tableBody += tableRow
725
726		tableRow += self._NameEntry(2 * level, state, testsuite, "ts")
727		for column in columns:
728			tableRow += column.TestsuiteCell(testsuite)
729
730		for ts in self._SortedValues(testsuite._testsuites):
731			self._RenderTestsuite(tableBody, columns, ts, level + 1)
732
733		for testcase in self._SortedValues(testsuite._testcases):
734			if testcase._status == self._showTestcases:
735				self._RenderTestcase(tableBody, columns, testcase, level + 1)
736
737	def _RenderTestcase(self, tableBody: nodes.tbody, columns: list[Column], testcase: Testcase, level: int) -> None:
738		"""
739		Add a row for a testcase.
740
741		:param tableBody: The table body the row is added to.
742		:param columns:   The columns after the first one.
743		:param testcase:  The testcase.
744		:param level:     The testcase's depth in the hierarchy, shown by indentation.
745		"""
746		state = self._ConvertTestcaseStatusToSymbol(testcase._status)
747
748		tableRow = nodes.row("", classes=["report-testcase", f"testcase-{testcase._status.name.lower()}"])
749		tableBody += tableRow
750
751		tableRow += self._NameEntry(2 * level, state, testcase, "tc")
752		for column in columns:
753			tableRow += column.TestcaseCell(testcase)
754
755	def _NameEntry(self, level: int, state: str, entity: Testsuite | Testcase, roleName: str) -> nodes.entry:
756		"""
757		Create the cell naming a testsuite or testcase, indented, and linked to its page if the report has pages.
758
759		:param level:    The number of indentation steps.
760		:param state:    The status symbol written before the name.
761		:param entity:   The testsuite or testcase.
762		:param roleName: The role referring to it: ``ts`` for a testsuite, ``tc`` for a testcase.
763		:returns:        The table cell.
764		"""
765		if (pages := self._Pages()) is not None and (entry := pages.Entry(entity)) is not None:
766			# a reference has to be inside a text element; an inline keeps the cell free of a paragraph, as the others are
767			reference = pages.CreateReference(roleName, entry, self.env.docname)
768			return nodes.entry("", ReportIndentation("", "", nodes.Text(state), reference, level=level))
769
770		return nodes.entry("", ReportIndentation("", f"{state}{entity.Name}", level=level))
771
772	def _RenderSummary(self, tableBody: nodes.tbody, columns: list[Column], testsuiteSummary: TestsuiteSummary) -> None:
773		"""
774		Add the summary row: the overall status and counts.
775
776		:param tableBody:        The table body the row is added to.
777		:param columns:          The columns after the first one.
778		:param testsuiteSummary: The testsuite summary.
779		"""
780		state = self._ConvertTestsuiteStatusToSymbol(testsuiteSummary._status)
781
782		tableRow = nodes.row(
783			"", classes=["report-summary", f"testsuitesummary-{testsuiteSummary._status.name.lower()}"]
784		)
785		tableBody += tableRow
786
787		tableRow += nodes.entry("", nodes.Text(f"{state} {testsuiteSummary.Status.name.upper()}"))
788		for column in columns:
789			tableRow += column.TestsuiteCell(testsuiteSummary)
790
791	def run(self) -> list[nodes.Node]:
792		"""
793		Read the configured report and return it as a table, on landscape pages in LaTeX.
794
795		The legend of the status symbols is drawn below the table, or above it, as option ``:legend:`` chooses.
796		``:legend: none`` draws no legend.
797
798		A report with pages adds a hidden table of contents of its top-level testsuites, so the pages are below this
799		document in the navigation.
800
801		:returns: A :class:`~pyTooling.Sphinx.Node.Landscape` container holding the table and its legend, or the error
802		          message; then the table of contents, if the report has pages.
803		"""
804		container = Landscape()
805
806		try:
807			self._CheckOptions()
808		except SphinxExtensionError as ex:
809			message = f"Caught {getTypeName(ex)} when checking options for directive '{self.directiveName}'."
810			return self._internalError(container, __name__, message, ex)
811
812		self._legend = self.options.get("legend", LegendPosition.DEFAULT)
813
814		if (readError := self._readErrors.get(self._reportID, None)) is not None:
815			if isinstance(readError, ReportsPackageMissingError):
816				message = f"Caught {getTypeName(readError)} when reading '{self._reportFile}'."
817			else:
818				message = f"Caught {getTypeName(readError)} when reading and parsing '{self._reportFile}'."
819			return self._internalError(container, __name__, message, readError)
820
821		try:
822			self._testsuite = self.GetReport(self._reportID)
823			container += self._GenerateTestSummaryTable()
824		except Exception as ex:
825			message = (
826				f"Caught {getTypeName(ex)} when generating the document structure for {self._REPORT_KIND.lower()} "
827				f"'{self._reportFile}'."
828			)
829			return self._internalError(container, __name__, message, ex)
830
831		if self._legend is LegendPosition.Top:
832			container.insert(0, self._CreateLegend())
833		elif self._legend is LegendPosition.Bottom:
834			container += self._CreateLegend()
835
836		if (pages := self._Pages()) is None:
837			return [container]
838
839		return [container, pages.TableOfContents(self.env.docname)]
840
841
842@export
843class UnittestSummary(UnittestSummaryBase):
844	"""
845	The ``report:unittest-summary`` directive: a table of a unit test report, per testsuite and testcase.
846	"""
847
848	directiveName: str = "unittest-summary"  #: Name the directive is invoked by.
849
850	has_content =        False  #: A boolean; ``True`` if content is allowed.
851	required_arguments = 0      #: Number of required directive arguments.
852	optional_arguments = 6      #: Number of optional arguments.
853
854	option_spec: dict[str, Any] = {  # type: ignore[misc]
855		"class":                  strip,
856		"reportid":               stripAndNormalize,
857		"testsuite-summary-name": strip,
858		"show-testcases":         stripAndNormalize,
859		"no-assertions":          flag,
860		"hide-testsuite-summary": flag,
861		"legend":                 LegendPosition.Parse
862	}  #: Mapping of option names to validator functions.
863
864	configValues: ClassVar[dict[str, tuple[Any, str, Any]]] = {
865		"Testsuites": ({}, "env", dict)
866	}  #: Values added to :file:`conf.py`, as ``name: (default, rebuild, types)``, prefixed by :data:`CONFIG_PREFIX`.
867
868	_CONFIG_VALUE: ClassVar[str] = f"{CONFIG_PREFIX}_Testsuites"   #: Configuration value declaring the reports.
869	_REPORT_KEY:   ClassVar[str] = "xml_report"                    #: Key of a report's file in its configuration.
870	_REPORT_KIND:  ClassVar[str] = "Unittest report"               #: The kind of report, as a message names it.
871
872	_testSummaries: ClassVar[dict[str, TestsuiteConfiguration]] = {}  #: Report configurations, by report ID.
873	_reports:       ClassVar[dict[str, TestsuiteSummary]] = {}        #: Reports read by :meth:`ReadReports`, by ID.
874	_readErrors:    ClassVar[dict[str, Exception]] = {}               #: Why a report couldn't be read, by report ID.
875
876	_noAssertions: bool  #: Whether the assertion column is left out.
877
878	def _CheckOptions(self) -> None:
879		"""
880		Parse all directive options or use default values.
881
882		:raises SphinxExtensionError: If option ``:class:`` isn't a list of CSS class names.
883		:raises SphinxExtensionError: If option ``:show-testcases:`` is neither ``all`` nor ``not-passed``.
884		:raises SphinxExtensionError: If option ``:reportid:`` is missing.
885		:raises ReportExtensionError: If ``pyTooling_Unittest_Testsuites`` has no entry for the report ID.
886		"""
887		cssClasses = self._ParseStringOption("class", "", r"(\w+)?( +\w+)*")
888		showTestcases = self._ParseStringOption("show-testcases", "all", r"all|not-passed")
889
890		self._cssClasses = [] if cssClasses == "" else cssClasses.split(" ")
891		self._reportID = self._ParseStringOption("reportid")
892		self._testsuiteSummaryName = self._ParseStringOption("testsuite-summary-name", "", r".+")
893		self._showTestcases = ShowTestcases[showTestcases.replace("-", "_")]
894		self._noAssertions = "no-assertions" in self.options
895		self._hideTestsuiteSummary = "hide-testsuite-summary" in self.options
896
897		try:
898			testSummary = self._testSummaries[self._reportID]
899		except KeyError as ex:
900			raise ReportExtensionError(f"No unit testing configuration item for '{self._reportID}'.") from ex
901		self._reportFile = testSummary["xml_report"]
902
903	def _Columns(self) -> list[Column | ColumnGroup]:
904		"""
905		Return the columns after the first one: the counts of testcases, skipped, errored, failed and passed ones, the
906		assertions - unless option ``:no-assertions:`` is given -, and the runtime.
907
908		:returns: The columns.
909		"""
910		def runtime(entity: TestsuiteBase[Any] | Testcase) -> str:
911			"""
912			Nested function formatting the runtime of a testsuite, testsuite summary or testcase.
913
914			:param entity: The testsuite, testsuite summary or testcase.
915			:returns:      The runtime.
916			"""
917			return self._FormatTimedelta(entity.TotalDuration)
918
919		columns: list[Column | ColumnGroup] = [
920			Column("Testcases", 1, testsuite=lambda testsuite: f"{testsuite.TestcaseCount}"),
921			Column("Skipped",   1, testsuite=lambda testsuite: f"{testsuite.Skipped}"),
922			Column("Errored",   1, testsuite=lambda testsuite: f"{testsuite.Errored}"),
923			Column("Failed",    1, testsuite=lambda testsuite: f"{testsuite.Failed}"),
924			Column("Passed",    1, testsuite=lambda testsuite: f"{testsuite.Passed}"),
925		]
926		if not self._noAssertions:
927			columns.append(Column("Assertions", 1, testcase=lambda testcase: f"{testcase.AssertionCount}"))
928		columns.append(Column("Runtime (HH:MM:SS.sss)", 2, testsuite=runtime, testcase=runtime))
929
930		return columns
931
932	def _Pages(self) -> Nullable[ReportPages[Any]]:
933		"""
934		Return the pages of the report, if it was declared with the key ``pages``.
935
936		:returns: The report's pages, or ``None`` if it has none.
937		"""
938		from pyTooling.Sphinx.UnittestPages import UnittestReportPages
939
940		if not UnittestReportPages.HasPages(self._reportID):
941			return None
942
943		return UnittestReportPages.GetPages(self._reportID)
944
945	@classmethod
946	def _ReadReport(cls, xmlReport: Path) -> TestsuiteSummary:
947		"""
948		Read a report file, choosing the reader by the file's root element.
949
950		:param xmlReport:                   The report file.
951		:returns:                           The report's testsuite summary, aggregated.
952		:raises ReportExtensionError:       If the file's root element is no known report format.
953		:raises ReportsPackageMissingError: If pyEDAA.Reports, or its reader for the report's format, isn't installed.
954		"""
955		_, rootElement = next(iterparse(xmlReport, events=("start",)))  # nosec B314 - the project's own report
956		try:
957			reportFormat = REPORT_FORMATS[rootElement.tag]
958		except KeyError:
959			ex = ReportExtensionError(f"Unittest report '{xmlReport}' has an unknown format.")
960			ex.add_note(f"Got root element '<{rootElement.tag}>'; supported: {', '.join(REPORT_FORMATS)}")
961			raise ex from None
962
963		if reportFormat == "pyTooling":
964			try:
965				from pyEDAA.Reports.Unittesting.pyTooling import Document as pyToolingDocument
966			except ImportError as cause:
967				raise ReportsPackageMissingError("Reading a pyTooling test report") from cause
968
969			return pyToolingDocument(xmlReport, analyzeAndConvert=True)
970
971		try:
972			from pyEDAA.Reports.Unittesting.JUnit import Document as JUnitDocument
973		except ImportError as cause:
974			raise ReportsPackageMissingError("Reading a unit test report") from cause
975
976		document = JUnitDocument(xmlReport, analyzeAndConvert=True)
977		testsuiteSummary = document.ToTestsuiteSummary()
978		testsuiteSummary.Aggregate()
979
980		return testsuiteSummary