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

1# The file is inspired by Tyrrrz/GitHubActionsTestLogger 

2# https://github.com/Tyrrrz/GitHubActionsTestLogger/blob/04fe7796a047dbd0e3cd6a46339b2a50f5125317/GitHubActionsTestLogger/TestSummary.cs 

3 

4# ruff: noqa: PLR2004 

5 

6import os 

7import pathlib 

8from collections import Counter 

9from itertools import chain 

10from typing import IO 

11 

12from competitive_verifier.models import ( 

13 FileResult, 

14 JudgeStatus, 

15 ResultStatus, 

16 TestcaseResult, 

17 VerifyCommandResult, 

18) 

19 

20SUCCESS = ResultStatus.SUCCESS 

21FAILURE = ResultStatus.FAILURE 

22SKIPPED = ResultStatus.SKIPPED 

23 

24 

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 

31 

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" 

41 

42 

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" 

49 

50 

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) 

56 

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") 

63 

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 ) 

94 

95 

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

104 

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 ) 

110 

111 fp.write("# ") 

112 

113 if counter.get(FAILURE): 

114 emoji_status = "❌" 

115 elif counter.get(SKIPPED): 

116 emoji_status = "⚠" 

117 else: 

118 emoji_status = "✔" 

119 

120 fp.write(emoji_status) 

121 fp.write(" ") 

122 fp.write(os.getenv("COMPETITIVE_VERIFY_SUMMARY_TITLE", "Verification result")) 

123 fp.write("\n\n") 

124 

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") 

134 

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) 

146 

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) 

163 

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) 

169 

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") 

187 

188 etb = TableWriter(fp, ["env", "name", "status", "Elapsed", "Memory"]) 

189 etb.write_table_line(*[":---"] * 2 + [":---:"] * 3) 

190 

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 ) 

199 

200 

201def _with_icon(icon: str, text: str) -> str: 

202 return icon + "&nbsp;&nbsp;" + text 

203 

204 

205class DisplayTestcaseResult(TestcaseResult): 

206 environment: str | None 

207 

208 @property 

209 def elapsed_str(self): 

210 return to_human_str_seconds(self.elapsed) 

211 

212 @property 

213 def memory_str(self): 

214 return to_human_str_mega_bytes(self.memory) if self.memory else "-"