Coverage for lobster/tools/cpptest/parser/test_case.py: 82%

181 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-06 09:51 +0000

1import re 

2from typing import List 

3 

4from lobster.tools.cpptest.parser.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 else: 

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: 185 ↛ 186line 185 didn't jump to line 186 because the loop on line 185 never started

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

342 requirement_uri = re.search(tag_http_named, requirement) 

343 if requirement_uri is None: 

344 return None 

345 else: 

346 return requirement_uri.group() 

347 

348 @staticmethod 

349 def _add_new_requirement_to_requirement_list( 

350 testcase, 

351 requirement_uri, 

352 tag_http_named 

353 ): 

354 """ 

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

356 list if not included yet 

357 

358 requirement_uri -- uri to requirement 

359 tag_http_named -- named http pattern to get requirement 

360 number itself 

361 """ 

362 if requirement_uri is None: 

363 return 

364 else: 

365 named_requirement_number_match = re.match( 

366 tag_http_named, 

367 requirement_uri 

368 ) 

369 requirement_number_dictionary = ( 

370 named_requirement_number_match.groupdict()) 

371 requirement_number = ( 

372 requirement_number_dictionary.get("number")) 

373 requirement_cb = "CB-#" + requirement_number 

374 if requirement_cb not in testcase.requirements: 

375 testcase.requirements.append(requirement_cb) 

376 

377 @staticmethod 

378 def _get_require_tags(match, filter_regex): 

379 """ 

380 Function to filter the given re.match. The 

381 resulting list will only contain the objects 

382 of the match that correspond to the filter. 

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

384 

385 match -- re.match object or string 

386 filter_regex -- filter to apply to the match 

387 """ 

388 

389 if not match: 

390 return [] 

391 

392 if isinstance(match, re.Match): 

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

394 

395 return re.findall(filter_regex, match) 

396 

397 @staticmethod 

398 def notracing_special_case(lines, the_range): 

399 notracing_tag = "NOTRACING" 

400 return list(filter( 

401 lambda x: notracing_tag in x, 

402 lines[the_range[0]: the_range[1]] 

403 )) 

404 

405 @staticmethod 

406 def get_range_for_doxygen_comments(lines, index_of_test_definition): 

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

408 has_at_least_one_comment = True 

409 index_pointer = index_of_test_definition - 1 

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

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

412 index_pointer -= 1 

413 else: 

414 has_at_least_one_comment = False 

415 break 

416 start_index = index_pointer \ 

417 if has_at_least_one_comment \ 

418 else index_pointer + 1 

419 doxygen_comments_line_range = (start_index, index_of_test_definition) 

420 return doxygen_comments_line_range