UnittestPages.py

Summary

Lines:

128 of 135 covered (94.8%)

Excluded lines:

3

Branches:

45 of 54 taken (83.3%), 9 lines partially

Coverage:

91.5%

Directory:

Sphinx

Missed lines:

209-210, 234, 279, 305, 315-316

Source

pyTooling/Sphinx/UnittestPages.pycoveredpartialuncoveredexcludednot executable
1# ==================================================================================================================== #
2#             _____           _ _               ____        _     _                                                    #
3#  _ __  _   |_   _|__   ___ | (_)_ __   __ _  / ___| _ __ | |__ (_)_ __ __  __                                        #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | \___ \| '_ \| '_ \| | '_ \\ \/ /                                        #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_ ___) | |_) | | | | | | | |>  <                                         #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)____/| .__/|_| |_|_|_| |_/_/\_\                                        #
7# |_|    |___/                          |___/        |_|                                                               #
8# ==================================================================================================================== #
9# Authors:                                                                                                             #
10#   Patrick Lehmann                                                                                                    #
11#                                                                                                                      #
12# License:                                                                                                             #
13# ==================================================================================================================== #
14# Copyright 2026-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 page per testsuite and per testcase of a unit test report, generated without a source file.
33
34A report declared in ``pyTooling_Unittest_Testsuites`` with the key ``pages`` gets a document per testsuite and per
35testcase, named below that key's document name:
36
37* a testsuite: ``<pages>/<testsuite>/<testsuite>/...``, the names of the testsuites from the report's summary down;
38* a testcase: ``<pages>/<testsuite>/.../<testcase>``.
39
40The pages are built from the report :class:`~pyTooling.Sphinx.Unittest.UnittestSummary` read, by the machinery of
41:class:`~pyTooling.Sphinx.Pages.ReportPages`: a testsuite's page lists its testsuites and testcases in a hidden table
42of contents, as the directive ``report:unittest-summary`` lists the top-level testsuites, so the pages are in the
43navigation below the summary's document. Every testsuite and testcase with a page is an object of domain ``report``,
44referred to by the roles ``:ts:`` and ``:tc:``.
45
46.. seealso::
47
48   :ref:`DIR/UnittestSummary/Pages`
49      |rarr| The pages and the roles, with examples.
50   :mod:`pyTooling.Sphinx.Pages`
51      |rarr| The pages of a report: document names, registration, generation and navigation.
52"""
53from __future__                 import annotations
54
55from textwrap                   import dedent
56from typing                     import TYPE_CHECKING, ClassVar, Iterable, Optional as Nullable
57
58from docutils                   import nodes
59from sphinx.application         import Sphinx
60from sphinx.util.logging        import getLogger
61
62from pyTooling.Decorators       import export
63
64from pyTooling.Sphinx           import ReportExtensionError
65from pyTooling.Sphinx.Pages     import ReportPages
66from pyTooling.Sphinx.Unittest  import UnittestSummary
67
68if TYPE_CHECKING:  # pragma: no cover
69	# pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
70	from pathlib                  import Path
71
72	from pyEDAA.Reports.Unittesting import Testcase, Testsuite, TestsuiteSummary
73
74
75@export
76class UnittestReportPages(ReportPages):
77	"""
78	The pages of one unit test report: a page per testsuite and per testcase.
79	"""
80
81	_reportPages: ClassVar[dict[str, ReportPages]] = {}  #: The pages of every unit test report with pages, by report ID.
82	_separator:   ClassVar[str] = "."                    #: Separator of the names in a qualified name.
83
84	_summary: TestsuiteSummary  #: The report's testsuite summary.
85
86	def __init__(self, reportID: str, prefix: str, xmlReport: Path, summary: TestsuiteSummary) -> None:
87		"""
88		Compute the document names and entries of a report's testsuites and testcases.
89
90		A testsuite or testcase whose qualified name is already taken by another of the same kind gets no page; this
91		is logged as a warning.
92
93		:param reportID:  Identifier of the report.
94		:param prefix:    Document name the pages are generated below.
95		:param xmlReport: The report file.
96		:param summary:   The report's testsuite summary.
97		"""
98		topLevel = [("ts", testsuite) for testsuite in sorted(summary._testsuites.values(), key=lambda item: item._name)]
99		super().__init__(reportID, prefix, xmlReport, topLevel)
100
101		self._summary = summary
102
103	@classmethod
104	def CreatePages(cls, sphinxApplication: Sphinx) -> None:
105		"""
106		Call-back for Sphinx' ``builder-inited`` event, after the reports are read: compute and register the pages.
107
108		Only a report with the key ``pages`` gets pages. A report that couldn't be read gets none; this is logged as an
109		error.
110
111		:param sphinxApplication: Sphinx application instance.
112		"""
113		cls._reportPages = {}
114		for reportID, testSummary in UnittestSummary._testSummaries.items():
115			if (prefix := testSummary["pages"]) is None:
116				continue
117
118			try:
119				summary = UnittestSummary.GetReport(reportID)
120			except ReportExtensionError as ex:
121				cause = ex if ex.__cause__ is None else ex.__cause__
122				getLogger(__name__).error(
123					f"Caught {ex.__class__.__name__} when generating the pages of unittest report '{reportID}'.\n"
124					f"  {cause.__class__.__name__}: {cause}"
125				)
126				continue
127
128			pages = cls(reportID, prefix, testSummary["xml_report"], summary)
129			cls._reportPages[reportID] = pages
130			pages.Register(sphinxApplication.env)
131
132	def _Name(self, entity: Testsuite | Testcase) -> str:
133		"""
134		Return a testsuite's or testcase's name.
135
136		:param entity: The testsuite or testcase.
137		:returns:      The name.
138		"""
139		return entity._name
140
141	def _Title(self, entity: Testsuite | Testcase) -> Nullable[str]:
142		"""
143		Return a testsuite's or testcase's title written for a reader, if it differs from its name.
144
145		:param entity: The testsuite or testcase.
146		:returns:      The title, or ``None``.
147		"""
148		return None if entity._title == entity._name else entity._title
149
150	def _Children(self, roleName: str, entity: Testsuite | Testcase) -> Iterable[tuple[str, Testsuite | Testcase]]:
151		"""
152		Return a testsuite's testsuites, then its testcases, each sorted by name; a testcase has none.
153
154		:param roleName: The role referring to the object: ``ts`` or ``tc``.
155		:param entity:   The testsuite or testcase.
156		:returns:        The children as ``(role name, object)`` pairs.
157		"""
158		if roleName == "tc":
159			return []
160
161		return [
162			*(("ts", testsuite) for testsuite in sorted(entity._testsuites.values(), key=lambda item: item._name)),
163			*(("tc", testcase) for testcase in sorted(entity._testcases.values(), key=lambda item: item._name))
164		]
165
166	def _Page(self, docName: str, roleName: str, entity: Testsuite | Testcase) -> nodes.section:
167		"""
168		Build a testsuite's or testcase's page.
169
170		:param docName:  Name of the page's document.
171		:param roleName: The role referring to the object: ``ts`` or ``tc``.
172		:param entity:   The testsuite or testcase.
173		:returns:        The page's top section.
174		"""
175		if roleName == "ts":
176			return self._TestsuitePage(docName, entity)
177		else:
178			return self._TestcasePage(docName, entity)
179
180	def _PageSection(self, entity: Testsuite | Testcase) -> nodes.section:
181		"""
182		Create a page's top section: the name as title, then the title, summary and description of the report, if any.
183
184		The title is shown as a lead line, if it differs from the name. The summary is shown, if it differs from the
185		title. The description is shown without its first paragraph, if that is the summary. The texts are shown as
186		plain text: a paragraph per block, and a literal block for an indented block.
187
188		:param entity: The testsuite or testcase.
189		:returns:      The section.
190		"""
191		section = nodes.section("", nodes.title(entity._name, entity._name), ids=["report-unittest-page"])
192
193		title, summary, description = entity._title, entity._summary, entity._description
194		if title is not None and title != entity._name:
195			section += nodes.paragraph("", "", nodes.strong(title, title), classes=["report-unittest-title"])
196
197		if summary is not None and summary != title:
198			section += nodes.paragraph(summary, summary, classes=["report-unittest-summary"])
199
200		if description is not None:
201			if summary is not None and description.startswith(summary):
202				description = description[len(summary):]
203
204			for block in description.strip("\n").split("\n\n"):
205				lines = [line for line in block.split("\n") if line.strip() != ""]
206				if len(lines) == 0:
207					continue
208				elif all(line[0] in " \t" for line in lines):
209					text = dedent("\n".join(lines))
210					section += nodes.literal_block(text, text, language="text", classes=["report-unittest-description"])
211				else:
212					text = " ".join(line.strip() for line in lines)
213					section += nodes.paragraph(text, text, classes=["report-unittest-description"])
214
215		return section
216
217	def _TestcasePage(self, docName: str, testcase: Testcase) -> nodes.section:
218		"""
219		Build a testcase's page: its status, duration, assertions and testsuite, its message and its captured output.
220
221		:param docName:  Name of the page's document.
222		:param testcase: The testcase.
223		:returns:        The page's top section.
224		"""
225		page = self._PageSection(testcase)
226		status = testcase._status
227
228		page += (summarySection := nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"]))
229		summarySection += (fieldList := nodes.field_list(classes=["report-unittest-fields"]))
230		symbol = UnittestSummary._ConvertTestcaseStatusToSymbol(status)
231		self._Field(fieldList, "Status", nodes.Text(f"{symbol} {status.name}"))
232		self._Field(fieldList, "Duration", nodes.Text(UnittestSummary._FormatTimedelta(testcase._totalDuration)))
233		if testcase._assertionCount is not None:
234			self._Field(fieldList, "Assertions", nodes.Text(f"{testcase._assertionCount}"))
235		self._Field(fieldList, "Testsuite", self._Reference("ts", testcase._parent, docName))
236
237		for identifier, title, texts in (
238			("message",         "Message",         (testcase._message, testcase._details)),
239			("standard-output", "Standard Output", (testcase._standardOutput, )),
240			("standard-error",  "Standard Error",  (testcase._standardError, )),
241		):
242			if all(text is None for text in texts):
243				continue
244
245			page += (section := nodes.section("", nodes.title(title, title), ids=[identifier]))
246			for text in texts:
247				if text is not None:
248					section += nodes.literal_block(text, text, language="text", classes=[f"report-unittest-{identifier}"])
249
250		return page
251
252	def _TestsuitePage(self, docName: str, testsuite: Testsuite) -> nodes.section:
253		"""
254		Build a testsuite's page: its status, counts, duration and assertions, then tables of its testsuites and
255		testcases, each linked to its page.
256
257		:param docName:   Name of the page's document.
258		:param testsuite: The testsuite.
259		:returns:         The page's top section.
260		"""
261		page = self._PageSection(testsuite)
262		status = testsuite._status
263		testcases = list(testsuite.IterateTestcases())
264		withAssertions = any(testcase._assertionCount is not None for testcase in testcases)
265
266		page += (summarySection := nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"]))
267		summarySection += (fieldList := nodes.field_list(classes=["report-unittest-fields"]))
268		symbol = UnittestSummary._ConvertTestsuiteStatusToSymbol(status)
269		self._Field(fieldList, "Status", nodes.Text(f"{symbol} {status.name}"))
270		self._Field(
271			fieldList, "Testcases",
272			nodes.Text(
273				f"{testsuite.TestcaseCount} - {testsuite.Passed} passed, {testsuite.Failed} failed, "
274				f"{testsuite.Errored} errored, {testsuite.Skipped} skipped"
275			)
276		)
277		self._Field(fieldList, "Duration", nodes.Text(UnittestSummary._FormatTimedelta(testsuite._totalDuration)))
278		if withAssertions:
279			self._Field(fieldList, "Assertions", nodes.Text(f"{testsuite.AssertionCount}"))
280
281		if (parent := testsuite._parent) is not None and parent is not self._summary:
282			self._Field(fieldList, "Testsuite", self._Reference("ts", parent, docName))
283
284		children: list[str] = []
285		if len(testsuite._testsuites) > 0:
286			page += (section := nodes.section("", nodes.title("Testsuites", "Testsuites"), ids=["testsuites"]))
287			table, tableBody = self._Table(
288				[("Testsuite", 6), ("Status", 2), ("Testcases", 1), ("Duration", 2)], ["report-unittest-testsuites"]
289			)
290			section += table
291			for nested in sorted(testsuite._testsuites.values(), key=lambda item: item._name):
292				symbol = UnittestSummary._ConvertTestsuiteStatusToSymbol(nested._status)
293				tableBody += (row := nodes.row("", classes=[f"testsuite-{nested._status.name.lower()}"]))
294				row += nodes.entry("", nodes.paragraph("", "", self._Reference("ts", nested, docName)))
295				row += nodes.entry("", nodes.paragraph("", f"{symbol} {nested._status.name}"))
296				row += nodes.entry("", nodes.paragraph("", f"{nested.TestcaseCount}"))
297				row += nodes.entry("", nodes.paragraph("", UnittestSummary._FormatTimedelta(nested._totalDuration)))
298				if (entry := self.Entry(nested)) is not None:
299					children.append(entry.docName)
300
301		if len(testsuite._testcases) > 0:
302			page += (section := nodes.section("", nodes.title("Testcases", "Testcases"), ids=["testcases"]))
303			columns = [("Testcase", 6), ("Status", 2), ("Duration", 2)]
304			if withAssertions:
305				columns.append(("Assertions", 1))
306			table, tableBody = self._Table(columns, ["report-unittest-testcases"])
307			section += table
308			for testcase in sorted(testsuite._testcases.values(), key=lambda item: item._name):
309				symbol = UnittestSummary._ConvertTestcaseStatusToSymbol(testcase._status)
310				tableBody += (row := nodes.row("", classes=[f"testcase-{testcase._status.name.lower()}"]))
311				row += nodes.entry("", nodes.paragraph("", "", self._Reference("tc", testcase, docName)))
312				row += nodes.entry("", nodes.paragraph("", f"{symbol} {testcase._status.name}"))
313				row += nodes.entry("", nodes.paragraph("", UnittestSummary._FormatTimedelta(testcase._totalDuration)))
314				if withAssertions:
315					assertions = "" if testcase._assertionCount is None else f"{testcase._assertionCount}"
316					row += nodes.entry("", nodes.paragraph("", assertions))
317
318				if (entry := self.Entry(testcase)) is not None:
319					children.append(entry.docName)
320
321		if len(children) > 0:
322			# body elements precede a section's subsections, so the hidden table of contents goes before 'Summary'
323			page.insert(page.index(summarySection), self._TableOfContents(docName, children))
324
325		return page