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

181 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-09-22 04:43 +0000

1# LOBSTER - Lightweight Open BMW Software Traceability Evidence Report 

2# Copyright (C) 2024-2026 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) 

3# 

4# This program is free software: you can redistribute it and/or modify 

5# it under the terms of the GNU Affero General Public License as 

6# published by the Free Software Foundation, either version 3 of the 

7# License, or (at your option) any later version. 

8# 

9# This program is distributed in the hope that it will be useful, but 

10# WITHOUT ANY WARRANTY; without even the implied warranty of 

11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

12# Affero General Public License for more details. 

13# 

14# You should have received a copy of the GNU Affero General Public 

15# License along with this program. If not, see 

16# <https://www.gnu.org/licenses/>. 

17 

18import re 

19from typing import List 

20 

21from lobster.tools.cpptest.constants import Constants 

22 

23 

24class TestCase: 

25 """ 

26 Class to represent a test case. 

27 

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

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

30 TEST(TestSuite, TestName) and its corresponding doxygen 

31 style documentation. 

32 The documentation is assumed to contain references to 

33 requirements and related test cases. 

34 

35 Limitations on the tag usage: 

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

37 times in the test documentation and can be written on 

38 multiple lines 

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

40 on multiple lines but only used once as tag 

41 """ 

42 

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

44 codebeamer_url: str = ''): 

45 self.constants = Constants(codebeamer_url) 

46 # File_name where the test case is located 

47 self.file_name = file 

48 # TestSuite from TEST(TestSuite, TestName) 

49 self.suite_name = self.constants.NON_EXISTING_INFO 

50 # TestName from TEST(TestSuite, TestName) 

51 self.test_name = self.constants.NON_EXISTING_INFO 

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

53 self.docu_start_line = 1 

54 # Last line of the doxygen style 

55 self.docu_end_line = 1 

56 # First line of the implementation (typically the line 

57 # TEST(TestSuite, TestName)) 

58 self.definition_start_line = ( 

59 1 

60 ) 

61 # Last line of the implementation 

62 self.definition_end_line = 1 

63 # List of @requirement values 

64 self.requirements = [] 

65 # List of @requiredby values 

66 self.required_by = [] 

67 # List of @defect values 

68 self.defect_tracking_ids = [] 

69 # Content of @version 

70 self.version_id = [] 

71 # Content of @test 

72 self.test = "" 

73 # Content of @testmethods 

74 self.testmethods = [] 

75 # Content of @brief 

76 self.brief = "" 

77 

78 self._set_test_details(lines, start_idx) 

79 

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

81 """ 

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

83 Missing information are replaced by placeholders. 

84 

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

86 lines -- lines to parse 

87 start_idx -- index into lines where to start parsing 

88 """ 

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

90 

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

92 src = "".join(src) 

93 self._set_test_and_suite_name(src) 

94 

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

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

97 self.docu_end_line = self.docu_range[1] 

98 self.definition_start_line = start_idx + 1 

99 self.definition_end_line = self.def_end 

100 

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

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

103 self.docu_end_line = self.docu_start_line 

104 

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

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

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

108 self._set_base_attributes() 

109 

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

111 """ 

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

113 i.e. the closing brace. 

114 

115 lines -- lines to parse 

116 start_idx -- index into lines where to start parsing 

117 """ 

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

119 nbraces = 0 

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

121 for character in lines[start_idx]: 

122 if character == char[0]: 

123 nbraces = nbraces + 1 

124 

125 if character == char[1]: 

126 nbraces = nbraces - 1 

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

128 return start_idx + 1 

129 

130 start_idx = start_idx + 1 

131 return -1 

132 

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

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

135 

136 if match: 

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

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

139 

140 def _set_base_attributes(self) -> None: 

141 self._get_requirements_from_docu_lines( 

142 self.constants.requirement, 

143 self.constants.REQUIREMENT_TAG, 

144 self.constants.requirement_tag_http, 

145 self.constants.requirement_tag_http_named 

146 ) 

147 

148 self.required_by = self._get_require_tags( 

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

150 self.constants.REQUIRED_BY_TAG 

151 ) 

152 

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

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

155 defect_tracking_cb_ids = self._get_require_tags( 

156 defect_found, 

157 self.constants.REQUIREMENT_TAG 

158 ) 

159 cb_list = sorted( 

160 [ 

161 defect_tracking_id.strip("CB-#") 

162 for defect_tracking_id in defect_tracking_cb_ids 

163 ] 

164 ) 

165 defect_tracking_oct_ids = self._get_require_tags( 

166 defect_found, 

167 self.constants.OCT_TAG 

168 ) 

169 oct_list = sorted( 

170 [ 

171 defect_tracking_id.strip("CB-#") 

172 for defect_tracking_id in defect_tracking_oct_ids 

173 ] 

174 ) 

175 self.defect_tracking_ids = cb_list + oct_list 

176 self.version_id = self._get_version_tag() 

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

178 self.testmethods = self._get_testmethod_tag() 

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

180 

181 def _get_requirements_from_docu_lines(self, 

182 general_pattern, 

183 tag, tag_http, 

184 tag_http_named): 

185 """ 

186 Function to search for requirements from docu lines 

187 

188 general_pattern -- pattern to search for requirement comment blocks 

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

190 tag_http -- http pattern to be searched in docu 

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

192 """ 

193 blocks = general_pattern.findall(self.docu_lines) 

194 if not blocks: 

195 return 

196 

197 http_requirements = [] 

198 for block in blocks: 

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

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

201 

202 for requirements_listed_behind_one_tag in http_requirements: 

203 for requirement in requirements_listed_behind_one_tag: 

204 requirement_uri = self._get_uri_from_requirement_detection( 

205 requirement, 

206 tag_http_named 

207 ) 

208 self._add_new_requirement_to_requirement_list( 

209 self, 

210 requirement_uri, 

211 tag_http_named 

212 ) 

213 

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

215 """ 

216 Returns a list of string of valid test methods 

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

218 the value is dropped from the list 

219 """ 

220 test_methods_list = [] 

221 test_methods = ( 

222 self._add_multiline_attribute(self.constants.TESTMETHODS)) 

223 for test_method in test_methods.split(): 

224 if test_method in self.constants.VALID_TESTMETHODS: 

225 test_methods_list.append(test_method) 

226 

227 return test_methods_list 

228 

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

230 """ 

231 Returns a list of versions as int 

232 If the number of version specified is less 

233 than the number of requirement linked, the last version 

234 of the list is added for all requirements 

235 """ 

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

237 versions_list = versions.split() 

238 if versions_list == []: 

239 versions_list = [float("nan")] 

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

241 last_value = versions_list[-1] 

242 versions_list.append(last_value) 

243 return versions_list 

244 

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

246 field = "" 

247 found = pattern.search(self.docu_lines) 

248 if found: 

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

250 .replace("*", " ") 

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

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

253 return field 

254 

255 @staticmethod 

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

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

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

259 return True 

260 return False 

261 

262 @staticmethod 

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

264 return TestCase.has_no_macro_or_commented_general( 

265 lines, 

266 start_idx, 

267 TestCase, 

268 Constants.TEST_CASE_INTRO 

269 ) 

270 

271 @staticmethod 

272 def has_no_macro_or_commented_general(lines, 

273 start_idx, 

274 case, 

275 case_intro) -> bool: 

276 """ 

277 Returns True is the test case does not start with 

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

279 """ 

280 line = lines[start_idx].strip() 

281 

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

283 # TEST_CASE_INTRO for TestCase 

284 # BENCHMARK_CASE_INTRO for BenchmarkTestCase 

285 if not case_intro.match(line): 

286 return True 

287 # If the test case is commented out 

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

289 return True 

290 return False 

291 

292 @staticmethod 

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

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

295 lines, 

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

297 ): 

298 return True 

299 if Constants.NON_EXISTING_INFO in (test_case.suite_name, 

300 test_case.test_name): 

301 return True 

302 

303 return False 

304 

305 @staticmethod 

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

307 """ 

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

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

310 otherwise None is returned. 

311 

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

313 lines -- lines to parse 

314 start_idx -- index into lines where to start parsing 

315 """ 

316 return TestCase.try_parse_general( 

317 file, 

318 lines, 

319 start_idx, 

320 TestCase, 

321 codebeamer_url 

322 ) 

323 

324 @staticmethod 

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

326 """ 

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

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

329 otherwise None is returned. 

330 

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

332 lines -- lines to parse 

333 start_idx -- index into lines where to start parsing 

334 case -- test case type 

335 """ 

336 if case.has_no_macro_or_commented(lines, start_idx): 

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

338 return None 

339 

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

341 

342 if case.is_special_case(lines, tc): 

343 return None 

344 

345 return tc 

346 

347 @staticmethod 

348 def _get_uri_from_requirement_detection(requirement, tag_http_named): 

349 """ 

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

351 

352 requirement -- requirement candidate 

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

354 candidate 

355 """ 

356 if requirement == "": 

357 return None 

358 

359 requirement_uri = re.search(tag_http_named, requirement) 

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

361 return None 

362 return requirement_uri.group() 

363 

364 @staticmethod 

365 def _add_new_requirement_to_requirement_list( 

366 testcase, 

367 requirement_uri, 

368 tag_http_named 

369 ): 

370 """ 

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

372 list if not included yet 

373 

374 requirement_uri -- uri to requirement 

375 tag_http_named -- named http pattern to get requirement 

376 number itself 

377 """ 

378 if requirement_uri is None: 

379 return 

380 

381 named_requirement_number_match = re.match( 

382 tag_http_named, 

383 requirement_uri 

384 ) 

385 requirement_number_dictionary = ( 

386 named_requirement_number_match.groupdict()) 

387 requirement_number = ( 

388 requirement_number_dictionary.get("number")) 

389 requirement_cb = "CB-#" + requirement_number 

390 if requirement_cb not in testcase.requirements: 

391 testcase.requirements.append(requirement_cb) 

392 

393 @staticmethod 

394 def _get_require_tags(match, filter_regex): 

395 """ 

396 Function to filter the given re.match. The 

397 resulting list will only contain the objects 

398 of the match that correspond to the filter. 

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

400 

401 match -- re.match object or string 

402 filter_regex -- filter to apply to the match 

403 """ 

404 

405 if not match: 

406 return [] 

407 

408 if isinstance(match, re.Match): 

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

410 

411 return re.findall(filter_regex, match) 

412 

413 @staticmethod 

414 def notracing_special_case(lines, the_range): 

415 notracing_tag = "NOTRACING" 

416 return list(filter( 

417 lambda x: notracing_tag in x, 

418 lines[the_range[0]: the_range[1]] 

419 )) 

420 

421 @staticmethod 

422 def get_range_for_doxygen_comments(lines, index_of_test_definition): 

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

424 has_at_least_one_comment = True 

425 index_pointer = index_of_test_definition - 1 

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

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

428 index_pointer -= 1 

429 else: 

430 has_at_least_one_comment = False 

431 break 

432 start_index = index_pointer \ 

433 if has_at_least_one_comment \ 

434 else index_pointer + 1 

435 doxygen_comments_line_range = (start_index, index_of_test_definition) 

436 return doxygen_comments_line_range