UnittestPages.py

Summary

Lines:

135 of 142 covered (95.1%)

Excluded lines:

3

Branches:

47 of 56 taken (83.9%), 9 lines partially

Coverage:

91.9%

Directory:

Sphinx

Missed lines:

210-211, 262, 277, 298-299, 330

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.Common           import getTypeName
63from pyTooling.Decorators       import export
64
65from pyTooling.Sphinx           import ReportExtensionError
66from pyTooling.Sphinx.Pages     import ReportPages
67from pyTooling.Sphinx.Unittest  import UnittestSummary
68
69if TYPE_CHECKING:  # pragma: no cover
70	# pyEDAA.Reports is an optional dependency (extra 'reports'), imported where a report is read.
71	from pathlib                  import Path
72
73	from pyEDAA.Reports.Unittesting import Testcase, Testsuite, TestsuiteSummary
74
75
76@export
77class UnittestReportPages(ReportPages["Testsuite | Testcase"]):
78	"""
79	The pages of one unit test report: a page per testsuite and per testcase.
80	"""
81
82	_pages:     ClassVar[dict[str, UnittestReportPages]] = {}  #: The pages of every unit test report, by report ID.
83	_separator: ClassVar[str] = "."                            #: Separator of the names in a qualified name.
84
85	_summary: TestsuiteSummary  #: The report's testsuite summary.
86
87	def __init__(self, reportID: str, prefix: str, xmlReport: Path, summary: TestsuiteSummary) -> None:
88		"""
89		Compute the document names and entries of a report's testsuites and testcases.
90
91		A testsuite or testcase whose qualified name is already taken by another of the same kind gets no page; this
92		is logged as a warning.
93
94		:param reportID:  Identifier of the report.
95		:param prefix:    Document name the pages are generated below.
96		:param xmlReport: The report file.
97		:param summary:   The report's testsuite summary.
98		"""
99		topLevel = [("ts", testsuite) for testsuite in sorted(summary._testsuites.values(), key=lambda item: item._name)]
100		super().__init__(reportID, prefix, xmlReport, topLevel)
101
102		self._summary = summary
103
104	@classmethod
105	def CreatePages(cls, sphinxApplication: Sphinx) -> None:
106		"""
107		Call-back for Sphinx' ``builder-inited`` event, after the reports are read: compute and register the pages.
108
109		Only a report with the key ``pages`` gets pages. A report that couldn't be read gets none; this is logged as an
110		error.
111
112		:param sphinxApplication: Sphinx application instance.
113		"""
114		cls._pages = {}
115		for reportID, testSummary in UnittestSummary._testSummaries.items():
116			if (prefix := testSummary["pages"]) is None:
117				continue
118
119			try:
120				summary = UnittestSummary.GetReport(reportID)
121			except ReportExtensionError as ex:
122				cause = ex if ex.__cause__ is None else ex.__cause__
123				getLogger(__name__).error(
124					f"Caught {getTypeName(ex)} when generating the pages of unittest report '{reportID}'.\n"
125					f"  {getTypeName(cause)}: {cause}"
126				)
127				continue
128
129			pages = cls(reportID, prefix, testSummary["xml_report"], summary)
130			cls._pages[reportID] = pages
131			pages.Register(sphinxApplication.env)
132
133	def _Name(self, entity: Testsuite | Testcase) -> str:
134		"""
135		Return a testsuite's or testcase's name.
136
137		:param entity: The testsuite or testcase.
138		:returns:      The name.
139		"""
140		return entity._name
141
142	def _Title(self, entity: Testsuite | Testcase) -> Nullable[str]:
143		"""
144		Return a testsuite's or testcase's title written for a reader, if it differs from its name.
145
146		:param entity: The testsuite or testcase.
147		:returns:      The title, or ``None``.
148		"""
149		return None if entity._title == entity._name else entity._title
150
151	def _Children(self, roleName: str, entity: Testsuite | Testcase) -> Iterable[tuple[str, Testsuite | Testcase]]:
152		"""
153		Return a testsuite's testsuites, then its testcases, each sorted by name; a testcase has none.
154
155		:param roleName: The role referring to the object: ``ts`` or ``tc``.
156		:param entity:   The testsuite or testcase.
157		:returns:        The children as ``(role name, object)`` pairs.
158		"""
159		if roleName == "tc":
160			return []
161
162		return [
163			*(("ts", testsuite) for testsuite in sorted(entity._testsuites.values(), key=lambda item: item._name)),
164			*(("tc", testcase) for testcase in sorted(entity._testcases.values(), key=lambda item: item._name))
165		]
166
167	def _Page(self, docName: str, roleName: str, entity: Testsuite | Testcase) -> nodes.section:
168		"""
169		Build a testsuite's or testcase's page.
170
171		:param docName:  Name of the page's document.
172		:param roleName: The role referring to the object: ``ts`` or ``tc``.
173		:param entity:   The testsuite or testcase.
174		:returns:        The page's top section.
175		"""
176		if roleName == "ts":
177			return self._TestsuitePage(docName, entity)
178		else:
179			return self._TestcasePage(docName, entity)
180
181	def _PageSection(self, entity: Testsuite | Testcase) -> nodes.section:
182		"""
183		Create a page's top section: the name as title, then the title, summary and description of the report, if any.
184
185		The title is shown as a lead line, if it differs from the name. The summary is shown, if it differs from the
186		title. The description is shown without its first paragraph, if that is the summary. The texts are shown as
187		plain text: a paragraph per block, and a literal block for an indented block.
188
189		:param entity: The testsuite or testcase.
190		:returns:      The section.
191		"""
192		section = nodes.section("", nodes.title(entity._name, entity._name), ids=["report-unittest-page"])
193
194		title, summary, description = entity._title, entity._summary, entity._description
195		if title is not None and title != entity._name:
196			section += nodes.paragraph("", "", nodes.strong(title, title), classes=["report-unittest-title"])
197
198		if summary is not None and summary != title:
199			section += nodes.paragraph(summary, summary, classes=["report-unittest-summary"])
200
201		if description is not None:
202			if summary is not None and description.startswith(summary):
203				description = description[len(summary):]
204
205			for block in description.strip("\n").split("\n\n"):
206				lines = [line for line in block.split("\n") if line.strip() != ""]
207				if len(lines) == 0:
208					continue
209				elif all(line[0] in " \t" for line in lines):
210					text = dedent("\n".join(lines))
211					section += nodes.literal_block(text, text, language="text", classes=["report-unittest-description"])
212				else:
213					text = " ".join(line.strip() for line in lines)
214					section += nodes.paragraph(text, text, classes=["report-unittest-description"])
215
216		return section
217
218	def _TestcasePage(self, docName: str, testcase: Testcase) -> nodes.section:
219		"""
220		Build a testcase's page: its status, duration, assertions and testsuite, its message and its captured output.
221
222		:param docName:  Name of the page's document.
223		:param testcase: The testcase.
224		:returns:        The page's top section.
225		"""
226		page = self._PageSection(testcase)
227
228		page += (summarySection := nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"]))
229		summarySection += (fieldList := nodes.field_list(classes=["report-unittest-fields"]))
230		self._TestcaseFields(fieldList, docName, testcase)
231
232		for identifier, title, texts in (
233			("message",         "Message",         (testcase._message, testcase._details)),
234			("standard-output", "Standard Output", (testcase._standardOutput, )),
235			("standard-error",  "Standard Error",  (testcase._standardError, )),
236		):
237			if all(text is None for text in texts):
238				continue
239
240			page += (section := nodes.section("", nodes.title(title, title), ids=[identifier]))
241			for text in texts:
242				if text is not None:
243					section += nodes.literal_block(text, text, language="text", classes=[f"report-unittest-{identifier}"])
244
245		return page
246
247	def _TestcaseFields(self, fieldList: nodes.field_list, docName: str, testcase: Testcase) -> None:
248		"""
249		Add the fields of a testcase's summary: its status, duration, assertions and testsuite.
250
251		A derived class adds fields of its own after them.
252
253		:param fieldList: The summary's field list.
254		:param docName:   Name of the testcase page's document.
255		:param testcase:  The testcase.
256		"""
257		status = testcase._status
258		symbol = UnittestSummary._ConvertTestcaseStatusToSymbol(status)
259		self._Field(fieldList, "Status", nodes.Text(f"{symbol} {status.name}"))
260		self._Field(fieldList, "Duration", nodes.Text(UnittestSummary._FormatTimedelta(testcase._totalDuration)))
261		if testcase._assertionCount is not None:
262			self._Field(fieldList, "Assertions", nodes.Text(f"{testcase._assertionCount}"))
263		self._Field(fieldList, "Testsuite", self._Reference("ts", testcase._parent, docName))
264
265	def _TestcaseColumns(self, withAssertions: bool) -> list[tuple[str, int]]:
266		"""
267		Return the columns of the testcase table of a testsuite's page: the testcase, its status, its duration and - if a
268		testcase of the testsuite counts them - its assertions.
269
270		A derived class adds columns of its own after them, and their cells in :meth:`_TestcaseCells`.
271
272		:param withAssertions: Whether a testcase of the testsuite counts assertions.
273		:returns:              One ``(title, width)`` pair per column.
274		"""
275		columns = [("Testcase", 6), ("Status", 2), ("Duration", 2)]
276		if withAssertions:
277			columns.append(("Assertions", 1))
278
279		return columns
280
281	def _TestcaseCells(self, docName: str, testcase: Testcase, withAssertions: bool) -> list[nodes.entry]:
282		"""
283		Create the cells of a testcase's row in the testcase table of a testsuite's page, one per column of
284		:meth:`_TestcaseColumns`.
285
286		:param docName:        Name of the testsuite page's document.
287		:param testcase:       The testcase.
288		:param withAssertions: Whether a testcase of the testsuite counts assertions.
289		:returns:              The cells.
290		"""
291		symbol = UnittestSummary._ConvertTestcaseStatusToSymbol(testcase._status)
292		cells = [
293			nodes.entry("", nodes.paragraph("", "", self._Reference("tc", testcase, docName))),
294			nodes.entry("", nodes.paragraph("", f"{symbol} {testcase._status.name}")),
295			nodes.entry("", nodes.paragraph("", UnittestSummary._FormatTimedelta(testcase._totalDuration)))
296		]
297		if withAssertions:
298			assertions = "" if testcase._assertionCount is None else f"{testcase._assertionCount}"
299			cells.append(nodes.entry("", nodes.paragraph("", assertions)))
300
301		return cells
302
303	def _TestsuitePage(self, docName: str, testsuite: Testsuite) -> nodes.section:
304		"""
305		Build a testsuite's page: its status, counts, duration and assertions, then tables of its testsuites and
306		testcases, each linked to its page.
307
308		:param docName:   Name of the page's document.
309		:param testsuite: The testsuite.
310		:returns:         The page's top section.
311		"""
312		page = self._PageSection(testsuite)
313		status = testsuite._status
314		testcases = list(testsuite.IterateTestcases())
315		withAssertions = any(testcase._assertionCount is not None for testcase in testcases)
316
317		page += (summarySection := nodes.section("", nodes.title("Summary", "Summary"), ids=["summary"]))
318		summarySection += (fieldList := nodes.field_list(classes=["report-unittest-fields"]))
319		symbol = UnittestSummary._ConvertTestsuiteStatusToSymbol(status)
320		self._Field(fieldList, "Status", nodes.Text(f"{symbol} {status.name}"))
321		self._Field(
322			fieldList, "Testcases",
323			nodes.Text(
324				f"{testsuite.TestcaseCount} - {testsuite.Passed} passed, {testsuite.Failed} failed, "
325				f"{testsuite.Errored} errored, {testsuite.Skipped} skipped"
326			)
327		)
328		self._Field(fieldList, "Duration", nodes.Text(UnittestSummary._FormatTimedelta(testsuite._totalDuration)))
329		if withAssertions:
330			self._Field(fieldList, "Assertions", nodes.Text(f"{testsuite.AssertionCount}"))
331
332		if (parent := testsuite._parent) is not None and parent is not self._summary:
333			self._Field(fieldList, "Testsuite", self._Reference("ts", parent, docName))
334
335		children: list[str] = []
336		if len(testsuite._testsuites) > 0:
337			page += (section := nodes.section("", nodes.title("Testsuites", "Testsuites"), ids=["testsuites"]))
338			table, tableBody = self._Table(
339				[("Testsuite", 6), ("Status", 2), ("Testcases", 1), ("Duration", 2)], ["report-unittest-testsuites"]
340			)
341			section += table
342			for nested in sorted(testsuite._testsuites.values(), key=lambda item: item._name):
343				symbol = UnittestSummary._ConvertTestsuiteStatusToSymbol(nested._status)
344				tableBody += (row := nodes.row("", classes=[f"testsuite-{nested._status.name.lower()}"]))
345				row += nodes.entry("", nodes.paragraph("", "", self._Reference("ts", nested, docName)))
346				row += nodes.entry("", nodes.paragraph("", f"{symbol} {nested._status.name}"))
347				row += nodes.entry("", nodes.paragraph("", f"{nested.TestcaseCount}"))
348				row += nodes.entry("", nodes.paragraph("", UnittestSummary._FormatTimedelta(nested._totalDuration)))
349				if (entry := self.Entry(nested)) is not None:
350					children.append(entry.docName)
351
352		if len(testsuite._testcases) > 0:
353			page += (section := nodes.section("", nodes.title("Testcases", "Testcases"), ids=["testcases"]))
354			table, tableBody = self._Table(self._TestcaseColumns(withAssertions), ["report-unittest-testcases"])
355			section += table
356			for testcase in sorted(testsuite._testcases.values(), key=lambda item: item._name):
357				tableBody += (row := nodes.row("", classes=[f"testcase-{testcase._status.name.lower()}"]))
358				for cell in self._TestcaseCells(docName, testcase, withAssertions):
359					row += cell
360
361				if (entry := self.Entry(testcase)) is not None:
362					children.append(entry.docName)
363
364		if len(children) > 0:
365			# body elements precede a section's subsections, so the hidden table of contents goes before 'Summary'
366			page.insert(page.index(summarySection), self._TableOfContents(docName, children))
367
368		return page