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