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:
- 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