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

177 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-26 14:55 +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 requirements 

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 search_result = general_pattern.search(self.docu_lines) 

177 if search_result is None: 

178 return 

179 else: 

180 self.requirements = self._get_require_tags(search_result, tag) 

181 http_requirements = self._get_require_tags(search_result, tag_http) 

182 for requirements_listed_behind_one_tag in http_requirements: 182 ↛ 183line 182 didn't jump to line 183 because the loop on line 182 never started

183 for requirement in requirements_listed_behind_one_tag: 

184 requirement_uri = self._get_uri_from_requirement_detection( 

185 requirement, 

186 tag_http_named 

187 ) 

188 self._add_new_requirement_to_requirement_list( 

189 self, 

190 requirement_uri, 

191 tag_http_named 

192 ) 

193 

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

195 """ 

196 Returns a list of string of valid test methods 

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

198 the value is dropped from the list 

199 """ 

200 test_methods_list = [] 

201 test_methods = ( 

202 self._add_multiline_attribute(self.constants.TESTMETHODS)) 

203 for test_method in test_methods.split(): 

204 if test_method in self.constants.VALID_TESTMETHODS: 

205 test_methods_list.append(test_method) 

206 

207 return test_methods_list 

208 

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

210 """ 

211 Returns a list of versions as int 

212 If the number of version specified is less 

213 than the number of requirement linked, the last version 

214 of the list is added for all requirements 

215 """ 

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

217 versions_list = versions.split() 

218 if versions_list == []: 

219 versions_list = [float("nan")] 

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

221 last_value = versions_list[-1] 

222 versions_list.append(last_value) 

223 return versions_list 

224 

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

226 field = "" 

227 found = pattern.search(self.docu_lines) 

228 if found: 

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

230 .replace("*", " ") 

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

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

233 return field 

234 

235 @staticmethod 

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

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

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

239 return True 

240 return False 

241 

242 @staticmethod 

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

244 return TestCase.has_no_macro_or_commented_general( 

245 lines, 

246 start_idx, 

247 TestCase, 

248 Constants.TEST_CASE_INTRO 

249 ) 

250 

251 @staticmethod 

252 def has_no_macro_or_commented_general(lines, 

253 start_idx, 

254 case, 

255 case_intro) -> bool: 

256 """ 

257 Returns True is the test case does not start with 

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

259 """ 

260 line = lines[start_idx].strip() 

261 

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

263 # TEST_CASE_INTRO for TestCase 

264 # BENCHMARK_CASE_INTRO for BenchmarkTestCase 

265 if not case_intro.match(line): 

266 return True 

267 # If the test case is commented out 

268 elif case.is_line_commented(lines, start_idx): 268 ↛ 269line 268 didn't jump to line 269 because the condition on line 268 was never true

269 return True 

270 return False 

271 

272 @staticmethod 

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

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

275 lines, 

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

277 ): 

278 return True 

279 elif Constants.NON_EXISTING_INFO in (test_case.suite_name, 

280 test_case.test_name): 

281 return True 

282 

283 return False 

284 

285 @staticmethod 

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

287 """ 

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

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

290 otherwise None is returned. 

291 

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

293 lines -- lines to parse 

294 start_idx -- index into lines where to start parsing 

295 """ 

296 return TestCase.try_parse_general( 

297 file, 

298 lines, 

299 start_idx, 

300 TestCase, 

301 codebeamer_url 

302 ) 

303 

304 @staticmethod 

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

306 """ 

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

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

309 otherwise None is returned. 

310 

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

312 lines -- lines to parse 

313 start_idx -- index into lines where to start parsing 

314 case -- test case type 

315 """ 

316 if case.has_no_macro_or_commented(lines, start_idx): 

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

318 return None 

319 

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

321 

322 if case.is_special_case(lines, tc): 

323 return None 

324 

325 return tc 

326 

327 @staticmethod 

328 def _get_uri_from_requirement_detection(requirement, tag_http_named): 

329 """ 

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

331 

332 requirement -- requirement candidate 

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

334 candidate 

335 """ 

336 if requirement == "": 

337 return None 

338 else: 

339 requirement_uri = re.search(tag_http_named, requirement) 

340 if requirement_uri is None: 

341 return None 

342 else: 

343 return requirement_uri.group() 

344 

345 @staticmethod 

346 def _add_new_requirement_to_requirement_list( 

347 testcase, 

348 requirement_uri, 

349 tag_http_named 

350 ): 

351 """ 

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

353 list if not included yet 

354 

355 requirement_uri -- uri to requirement 

356 tag_http_named -- named http pattern to get requirement 

357 number itself 

358 """ 

359 if requirement_uri is None: 

360 return 

361 else: 

362 named_requirement_number_match = re.match( 

363 tag_http_named, 

364 requirement_uri 

365 ) 

366 requirement_number_dictionary = ( 

367 named_requirement_number_match.groupdict()) 

368 requirement_number = ( 

369 requirement_number_dictionary.get("number")) 

370 requirement_cb = "CB-#" + requirement_number 

371 if requirement_cb not in testcase.requirements: 

372 testcase.requirements.append(requirement_cb) 

373 

374 @staticmethod 

375 def _get_require_tags(match, filter_regex): 

376 """ 

377 Function to filter the given re.match. The 

378 resulting list will only contain the objects 

379 of the match that correspond to the filter. 

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

381 

382 match -- re.match object 

383 filter_regex -- filter to apply to the match 

384 """ 

385 

386 if not match: 

387 return [] 

388 

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

390 

391 @staticmethod 

392 def notracing_special_case(lines, the_range): 

393 notracing_tag = "NOTRACING" 

394 return list(filter( 

395 lambda x: notracing_tag in x, 

396 lines[the_range[0]: the_range[1]] 

397 )) 

398 

399 @staticmethod 

400 def get_range_for_doxygen_comments(lines, index_of_test_definition): 

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

402 has_at_least_one_comment = True 

403 index_pointer = index_of_test_definition - 1 

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

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

406 index_pointer -= 1 

407 else: 

408 has_at_least_one_comment = False 

409 break 

410 start_index = index_pointer \ 

411 if has_at_least_one_comment \ 

412 else index_pointer + 1 

413 doxygen_comments_line_range = (start_index, index_of_test_definition) 

414 return doxygen_comments_line_range