Coverage for src / competitive_verifier / summary.py: 100%
119 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-05 16:00 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-05 16:00 +0000
1# The file is inspired by Tyrrrz/GitHubActionsTestLogger
2# https://github.com/Tyrrrz/GitHubActionsTestLogger/blob/04fe7796a047dbd0e3cd6a46339b2a50f5125317/GitHubActionsTestLogger/TestSummary.cs
4# ruff: noqa: PLR2004
6import os
7import pathlib
8from collections import Counter
9from itertools import chain
10from typing import IO
12from competitive_verifier.models import (
13 FileResult,
14 JudgeStatus,
15 ResultStatus,
16 TestcaseResult,
17 VerifyCommandResult,
18)
20SUCCESS = ResultStatus.SUCCESS
21FAILURE = ResultStatus.FAILURE
22SKIPPED = ResultStatus.SKIPPED
25def to_human_str_seconds(total_seconds: float) -> str:
26 hours = int(total_seconds // 3600)
27 rm = total_seconds % 3600
28 minutes = int(rm // 60)
29 rm %= 60
30 seconds = rm
32 if hours > 0:
33 return f"{hours}h {minutes}m"
34 if minutes > 0:
35 return f"{minutes}m {int(seconds)}s"
36 if total_seconds >= 10:
37 return f"{int(seconds)}s"
38 if total_seconds > 1:
39 return f"{total_seconds:.1f}s"
40 return f"{int(total_seconds * 1000)}ms"
43def to_human_str_mega_bytes(total_mega_bytes: float) -> str:
44 if total_mega_bytes < 0.001:
45 return "0MB"
46 if total_mega_bytes < 100:
47 return f"{total_mega_bytes:.3g}MB"
48 return f"{int(total_mega_bytes)}MB"
51class TableWriter:
52 def __init__(self, fp: IO[str], header: list[str]) -> None:
53 self.size = len(header)
54 self.fp = fp
55 self.write_table_line(*header)
57 def write_table_line(self, *cells: str) -> None:
58 fp = self.fp
59 for c in cells:
60 fp.write("|")
61 fp.write(c)
62 fp.write("|\n")
64 def write_table_file_result(
65 self, results: list[tuple[pathlib.Path, FileResult]]
66 ) -> None:
67 for p, fr in results:
68 counter = Counter(r.status for r in fr.verifications)
69 if counter.get(FAILURE):
70 emoji_status = "❌"
71 elif counter.get(SKIPPED):
72 emoji_status = "⚠"
73 else:
74 emoji_status = "✔"
75 elapsed = sum(r.elapsed for r in fr.verifications)
76 slowest = max(
77 (r.slowest for r in fr.verifications if r.slowest is not None),
78 default=None,
79 )
80 heaviest = max(
81 (r.heaviest for r in fr.verifications if r.heaviest is not None),
82 default=None,
83 )
84 self.write_table_line(
85 _with_icon(emoji_status, p.as_posix()),
86 str(counter.get(SUCCESS, "-")),
87 str(counter.get(FAILURE, "-")),
88 str(counter.get(SKIPPED, "-")),
89 str(sum(counter.values())),
90 to_human_str_seconds(elapsed),
91 "-" if slowest is None else to_human_str_seconds(slowest),
92 "-" if heaviest is None else to_human_str_mega_bytes(heaviest),
93 )
96def write_summary(fp: IO[str], result: VerifyCommandResult):
97 file_results: list[tuple[pathlib.Path, FileResult]] = []
98 past_results: list[tuple[pathlib.Path, FileResult]] = []
99 for p, fr in result.files.items():
100 if fr.newest:
101 file_results.append((p, fr))
102 else:
103 past_results.append((p, fr))
105 file_results.sort(key=lambda t: t[0])
106 past_results.sort(key=lambda t: t[0])
107 counter = Counter(
108 r.status for r in chain.from_iterable(f[1].verifications for f in file_results)
109 )
111 fp.write("# ")
113 if counter.get(FAILURE):
114 emoji_status = "❌"
115 elif counter.get(SKIPPED):
116 emoji_status = "⚠"
117 else:
118 emoji_status = "✔"
120 fp.write(emoji_status)
121 fp.write(" ")
122 fp.write(os.getenv("COMPETITIVE_VERIFY_SUMMARY_TITLE", "Verification result"))
123 fp.write("\n\n")
125 fp.write("- ")
126 fp.write(_with_icon("✔", "All test case results are `success`"))
127 fp.write("\n")
128 fp.write("- ")
129 fp.write(_with_icon("❌", "Test case results containts `failure`"))
130 fp.write("\n")
131 fp.write("- ")
132 fp.write(_with_icon("⚠", "Test case results containts `skipped`"))
133 fp.write("\n\n\n")
135 header = [
136 _with_icon("📝", "File"),
137 "✔<br>Passed",
138 "❌<br>Failed",
139 "⚠<br>Skipped",
140 "∑<br>Total",
141 "⏳<br>Elapsed",
142 "🦥<br>Slowest",
143 "🐘<br>Heaviest",
144 ]
145 alignment = [":---"] + [":---:"] * (len(header) - 1)
147 if file_results:
148 fp.write("## Results\n")
149 tb = TableWriter(fp, header)
150 tb.write_table_line(*alignment)
151 tb.write_table_line(
152 "_**Sum**_",
153 str(counter.get(SUCCESS, "-")),
154 str(counter.get(FAILURE, "-")),
155 str(counter.get(SKIPPED, "-")),
156 str(sum(counter.values())),
157 to_human_str_seconds(result.total_seconds),
158 "-",
159 "-",
160 )
161 tb.write_table_line(*[""] * len(header))
162 tb.write_table_file_result(file_results)
164 if past_results:
165 fp.write("## Past results\n")
166 tb = TableWriter(fp, header)
167 tb.write_table_line(*alignment)
168 tb.write_table_file_result(past_results)
170 if counter.get(FAILURE):
171 first_failure = True
172 for p, fr in file_results:
173 cases = [
174 DisplayTestcaseResult(
175 environment=v.verification_name, **(c.model_dump())
176 )
177 for v in fr.verifications
178 for c in (v.testcases or [])
179 if c.status != JudgeStatus.AC
180 ]
181 if not cases:
182 continue
183 if first_failure:
184 fp.write("## Failed tests\n\n")
185 first_failure = False
186 fp.write(f"### {p.as_posix()}\n\n")
188 etb = TableWriter(fp, ["env", "name", "status", "Elapsed", "Memory"])
189 etb.write_table_line(*[":---"] * 2 + [":---:"] * 3)
191 for c in cases:
192 etb.write_table_line(
193 c.environment or "",
194 c.name,
195 c.status.name,
196 c.elapsed_str,
197 c.memory_str,
198 )
201def _with_icon(icon: str, text: str) -> str:
202 return icon + " " + text
205class DisplayTestcaseResult(TestcaseResult):
206 environment: str | None
208 @property
209 def elapsed_str(self):
210 return to_human_str_seconds(self.elapsed)
212 @property
213 def memory_str(self):
214 return to_human_str_mega_bytes(self.memory) if self.memory else "-"