Coverage for lobster/tools/cpptest/testcase.py: 93%

181 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-04-16 05:31 +0000

1import re 

2from typing import List 

3 

4from lobster.tools.cpptest.constants import Constants 

5 

6 

7class TestCase: 

8 """ 

9 Class to represent a test case. 

10 

11 In case of a c++ file a test case is considered 

12 to be the combination of a gtest fixture, e.g. 

13 TEST(TestSuite, TestName) and its corresponding doxygen 

14 style documentation. 

15 The documentation is assumed to contain references to 

16 requirements and related test cases. 

17 

18 Limitations on the tag usage: 

19 - @requirement, @requiredby & @defect can be used multiple 

20 times in the test documentation and can be written on 

21 multiple lines 

22 - @brief, @test, @testmethods and @version can be written 

23 on multiple lines but only used once as tag 

24 """ 

25 

26 def __init__(self, file: str, lines: List[str], start_idx: int, 

27 codebeamer_url: str = ''): 

28 self.constants = Constants(codebeamer_url) 

29 # File_name where the test case is located 

30 self.file_name = file 

31 # TestSuite from TEST(TestSuite, TestName) 

32 self.suite_name = self.constants.NON_EXISTING_INFO 

33 # TestName from TEST(TestSuite, TestName) 

34 self.test_name = self.constants.NON_EXISTING_INFO 

35 # First line of the doxygen style doc for the test case 

36 self.docu_start_line = 1 

37 # Last line of the doxygen style 

38 self.docu_end_line = 1 

39 # First line of the implementation (typically the line 

40 # TEST(TestSuite, TestName)) 

41 self.definition_start_line = ( 

42 1 

43 ) 

44 # Last line of the implementation 

45 self.definition_end_line = 1 

46 # List of @requirement values 

47 self.requirements = [] 

48 # List of @requiredby values 

49 self.required_by = [] 

50 # List of @defect values 

51 self.defect_tracking_ids = [] 

52 # Content of @version 

53 self.version_id = [] 

54 # Content of @test 

55 self.test = "" 

56 # Content of @testmethods 

57 self.testmethods = [] 

58 # Content of @brief 

59 self.brief = "" 

60 

61 self._set_test_details(lines, start_idx) 

62 

63 def _set_test_details(self, lines, start_idx) -> None: 

64 """ 

65 Parse the given range of lines for a valid test case. 

66 Missing information are replaced by placeholders. 

67 

68 file -- path to file that the following lines belong to 

69 lines -- lines to parse 

70 start_idx -- index into lines where to start parsing 

71 """ 

72 self.def_end = self._definition_end(lines, start_idx) 

73 

74 src = [line.strip() for line in lines[start_idx : self.def_end]] 

75 src = "".join(src) 

76 self._set_test_and_suite_name(src) 

77 

78 self.docu_range = self.get_range_for_doxygen_comments(lines, start_idx) 

79 self.docu_start_line = self.docu_range[0] + 1 

80 self.docu_end_line = self.docu_range[1] 

81 self.definition_start_line = start_idx + 1 

82 self.definition_end_line = self.def_end 

83 

84 if self.docu_range[0] == self.docu_range[1]: 

85 self.docu_start_line = self.docu_range[0] + 1 

86 self.docu_end_line = self.docu_start_line 

87 

88 self.docu_lines = [line.strip() for line in 

89 lines[self.docu_range[0]: self.docu_range[1]]] 

90 self.docu_lines = " ".join(self.docu_lines) 

91 self._set_base_attributes() 

92 

93 def _definition_end(self, lines, start_idx) -> int: 

94 """ 

95 Function to find the last line of test case definition, 

96 i.e. the closing brace. 

97 

98 lines -- lines to parse 

99 start_idx -- index into lines where to start parsing 

100 """ 

101 char = ["{", "}"] 

102 nbraces = 0 

103 while start_idx < len(lines): 103 ↛ 114line 103 didn't jump to line 114 because the condition on line 103 was always true

104 for character in lines[start_idx]: 

105 if character == char[0]: 

106 nbraces = nbraces + 1 

107 

108 if character == char[1]: 

109 nbraces = nbraces - 1 

110 if nbraces == 0: 110 ↛ 104line 110 didn't jump to line 104 because the condition on line 110 was always true

111 return start_idx + 1 

112 

113 start_idx = start_idx + 1 

114 return -1 

115 

116 def _set_test_and_suite_name(self, src) -> None: 

117 match = self.constants.TEST_CASE_INFO.search(src) 

118 

119 if match: 

120 self.test_name = match.groupdict().get("test_name") 

121 self.suite_name = match.groupdict().get("suite_name") 

122 

123 def _set_base_attributes(self) -> None: 

124 self._get_requirements_from_docu_lines( 

125 self.constants.requirement, 

126 self.constants.REQUIREMENT_TAG, 

127 self.constants.requirement_tag_http, 

128 self.constants.requirement_tag_http_named 

129 ) 

130 

131 self.required_by = self._get_require_tags( 

132 self.constants.REQUIRED_BY.search(self.docu_lines), 

133 self.constants.REQUIRED_BY_TAG 

134 ) 

135 

136 defect_found = self.constants.DEFECT.search(self.docu_lines) 

137 if defect_found: 137 ↛ 138line 137 didn't jump to line 138 because the condition on line 137 was never true

138 defect_tracking_cb_ids = self._get_require_tags( 

139 defect_found, 

140 self.constants.REQUIREMENT_TAG 

141 ) 

142 cb_list = sorted( 

143 [ 

144 defect_tracking_id.strip("CB-#") 

145 for defect_tracking_id in defect_tracking_cb_ids 

146 ] 

147 ) 

148 defect_tracking_oct_ids = self._get_require_tags( 

149 defect_found, 

150 self.constants.OCT_TAG 

151 ) 

152 oct_list = sorted( 

153 [ 

154 defect_tracking_id.strip("CB-#") 

155 for defect_tracking_id in defect_tracking_oct_ids 

156 ] 

157 ) 

158 self.defect_tracking_ids = cb_list + oct_list 

159 self.version_id = self._get_version_tag() 

160 self.test = self._add_multiline_attribute(self.constants.TEST) 

161 self.testmethods = self._get_testmethod_tag() 

162 self.brief = self._add_multiline_attribute(self.constants.BRIEF) 

163 

164 def _get_requirements_from_docu_lines(self, 

165 general_pattern, 

166 tag, tag_http, 

167 tag_http_named): 

168 """ 

169 Function to search for requirements from docu lines 

170 

171 general_pattern -- pattern to search for requirement comment blocks 

172 tag -- CB-# tag pattern to be searched in docu 

173 tag_http -- http pattern to be searched in docu 

174 tag_http_named -- named http pattern to be searched in docu 

175 """ 

176 blocks = general_pattern.findall(self.docu_lines) 

177 if not blocks: 

178 return 

179 

180 http_requirements = [] 

181 for block in blocks: 

182 self.requirements.extend(self._get_require_tags(block, tag)) 

183 http_requirements.extend(self._get_require_tags(block, tag_http)) 

184 

185 for requirements_listed_behind_one_tag in http_requirements: 

186 for requirement in requirements_listed_behind_one_tag: 

187 requirement_uri = self._get_uri_from_requirement_detection( 

188 requirement, 

189 tag_http_named 

190 ) 

191 self._add_new_requirement_to_requirement_list( 

192 self, 

193 requirement_uri, 

194 tag_http_named 

195 ) 

196 

197 def _get_testmethod_tag(self) -> List[str]: 

198 """ 

199 Returns a list of string of valid test methods 

200 If a test method used in the tag is not valid, 

201 the value is dropped from the list 

202 """ 

203 test_methods_list = [] 

204 test_methods = ( 

205 self._add_multiline_attribute(self.constants.TESTMETHODS)) 

206 for test_method in test_methods.split(): 

207 if test_method in self.constants.VALID_TESTMETHODS: 

208 test_methods_list.append(test_method) 

209 

210 return test_methods_list 

211 

212 def _get_version_tag(self) -> List[int]: 

213 """ 

214 Returns a list of versions as int 

215 If the number of version specified is less 

216 than the number of requirement linked, the last version 

217 of the list is added for all requirements 

218 """ 

219 versions = self._add_multiline_attribute(self.constants.VERSION) 

220 versions_list = versions.split() 

221 if versions_list == []: 

222 versions_list = [float("nan")] 

223 while len(self.requirements) > len(versions_list): 

224 last_value = versions_list[-1] 

225 versions_list.append(last_value) 

226 return versions_list 

227 

228 def _add_multiline_attribute(self, pattern) -> str: 

229 field = "" 

230 found = pattern.search(self.docu_lines) 

231 if found: 

232 field = (found.group(2).replace("/", " ") 

233 .replace("*", " ") 

234 .replace(",", " ")) if found else "" 

235 field = " ".join(field.split()) 

236 return field 

237 

238 @staticmethod 

239 def is_line_commented(lines, start_idx) -> bool: 

240 commented = re.compile(r"^\s*(//|\*|/\*)") 

241 if commented.match(lines[start_idx]): 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

242 return True 

243 return False 

244 

245 @staticmethod 

246 def has_no_macro_or_commented(lines, start_idx) -> bool: 

247 return TestCase.has_no_macro_or_commented_general( 

248 lines, 

249 start_idx, 

250 TestCase, 

251 Constants.TEST_CASE_INTRO 

252 ) 

253 

254 @staticmethod 

255 def has_no_macro_or_commented_general(lines, 

256 start_idx, 

257 case, 

258 case_intro) -> bool: 

259 """ 

260 Returns True is the test case does not start with 

261 an INTRO, or if the test case is commented out 

262 """ 

263 line = lines[start_idx].strip() 

264 

265 # If the test case does not start with a : 

266 # TEST_CASE_INTRO for TestCase 

267 # BENCHMARK_CASE_INTRO for BenchmarkTestCase 

268 if not case_intro.match(line): 

269 return True 

270 # If the test case is commented out 

271 if case.is_line_commented(lines, start_idx): 271 ↛ 272line 271 didn't jump to line 272 because the condition on line 271 was never true

272 return True 

273 return False 

274 

275 @staticmethod 

276 def is_special_case(lines, test_case) -> bool: 

277 if TestCase.notracing_special_case( 277 ↛ 281line 277 didn't jump to line 281 because the condition on line 277 was never true

278 lines, 

279 (test_case.docu_start_line - 1, test_case.docu_end_line) 

280 ): 

281 return True 

282 if Constants.NON_EXISTING_INFO in (test_case.suite_name, 

283 test_case.test_name): 

284 return True 

285 

286 return False 

287 

288 @staticmethod 

289 def try_parse(file, lines, start_idx, codebeamer_url = ""): 

290 """ 

291 Function to parse the given range of lines for a valid test case. 

292 If a valid test case is found a TestCase object is returned, 

293 otherwise None is returned. 

294 

295 file -- path to file that the following lines belong to 

296 lines -- lines to parse 

297 start_idx -- index into lines where to start parsing 

298 """ 

299 return TestCase.try_parse_general( 

300 file, 

301 lines, 

302 start_idx, 

303 TestCase, 

304 codebeamer_url 

305 ) 

306 

307 @staticmethod 

308 def try_parse_general(file, lines, start_idx, case, codebeamer_url): 

309 """ 

310 Function to parse the given range of lines for a valid general case. 

311 If a valid general case is found a Case object is returned, 

312 otherwise None is returned. 

313 

314 file -- path to file that the following lines belong to 

315 lines -- lines to parse 

316 start_idx -- index into lines where to start parsing 

317 case -- test case type 

318 """ 

319 if case.has_no_macro_or_commented(lines, start_idx): 

320 # If the test does not follow the convention, None is returned 

321 return None 

322 

323 tc = case(file, lines, start_idx, codebeamer_url) 

324 

325 if case.is_special_case(lines, tc): 

326 return None 

327 

328 return tc 

329 

330 @staticmethod 

331 def _get_uri_from_requirement_detection(requirement, tag_http_named): 

332 """ 

333 Function to get uri itself (without @requirement or similar) 

334 

335 requirement -- requirement candidate 

336 tag_http_named -- http pattern to search for uri in requirement 

337 candidate 

338 """ 

339 if requirement == "": 

340 return None 

341 

342 requirement_uri = re.search(tag_http_named, requirement) 

343 if requirement_uri is None: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true

344 return None 

345 return requirement_uri.group() 

346 

347 @staticmethod 

348 def _add_new_requirement_to_requirement_list( 

349 testcase, 

350 requirement_uri, 

351 tag_http_named 

352 ): 

353 """ 

354 Function to add new, non-None requirement to requirement 

355 list if not included yet 

356 

357 requirement_uri -- uri to requirement 

358 tag_http_named -- named http pattern to get requirement 

359 number itself 

360 """ 

361 if requirement_uri is None: 

362 return 

363 

364 named_requirement_number_match = re.match( 

365 tag_http_named, 

366 requirement_uri 

367 ) 

368 requirement_number_dictionary = ( 

369 named_requirement_number_match.groupdict()) 

370 requirement_number = ( 

371 requirement_number_dictionary.get("number")) 

372 requirement_cb = "CB-#" + requirement_number 

373 if requirement_cb not in testcase.requirements: 

374 testcase.requirements.append(requirement_cb) 

375 

376 @staticmethod 

377 def _get_require_tags(match, filter_regex): 

378 """ 

379 Function to filter the given re.match. The 

380 resulting list will only contain the objects 

381 of the match that correspond to the filter. 

382 If the match is empty an empty list is returned. 

383 

384 match -- re.match object or string 

385 filter_regex -- filter to apply to the match 

386 """ 

387 

388 if not match: 

389 return [] 

390 

391 if isinstance(match, re.Match): 

392 return re.findall(filter_regex, match.group(0)) 

393 

394 return re.findall(filter_regex, match) 

395 

396 @staticmethod 

397 def notracing_special_case(lines, the_range): 

398 notracing_tag = "NOTRACING" 

399 return list(filter( 

400 lambda x: notracing_tag in x, 

401 lines[the_range[0]: the_range[1]] 

402 )) 

403 

404 @staticmethod 

405 def get_range_for_doxygen_comments(lines, index_of_test_definition): 

406 comments = ["///", "//", "/*", "*"] 

407 has_at_least_one_comment = True 

408 index_pointer = index_of_test_definition - 1 

409 while index_pointer > 0: 409 ↛ 415line 409 didn't jump to line 415 because the condition on line 409 was always true

410 if any(x in lines[index_pointer] for x in comments): 

411 index_pointer -= 1 

412 else: 

413 has_at_least_one_comment = False 

414 break 

415 start_index = index_pointer \ 

416 if has_at_least_one_comment \ 

417 else index_pointer + 1 

418 doxygen_comments_line_range = (start_index, index_of_test_definition) 

419 return doxygen_comments_line_range