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
« 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/>.
18import re
19from typing import List
21from lobster.tools.cpptest.constants import Constants
24class TestCase:
25 """
26 Class to represent a test case.
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.
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 """
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 = ""
78 self._set_test_details(lines, start_idx)
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.
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)
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)
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
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
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()
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.
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
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
130 start_idx = start_idx + 1
131 return -1
133 def _set_test_and_suite_name(self, src) -> None:
134 match = self.constants.TEST_CASE_INFO.search(src)
136 if match:
137 self.test_name = match.groupdict().get("test_name")
138 self.suite_name = match.groupdict().get("suite_name")
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 )
148 self.required_by = self._get_require_tags(
149 self.constants.REQUIRED_BY.search(self.docu_lines),
150 self.constants.REQUIRED_BY_TAG
151 )
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)
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
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
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))
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 )
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)
227 return test_methods_list
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
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
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
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 )
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()
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
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
303 return False
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.
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 )
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.
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
340 tc = case(file, lines, start_idx, codebeamer_url)
342 if case.is_special_case(lines, tc):
343 return None
345 return tc
347 @staticmethod
348 def _get_uri_from_requirement_detection(requirement, tag_http_named):
349 """
350 Function to get uri itself (without @requirement or similar)
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
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()
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
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
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)
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.
401 match -- re.match object or string
402 filter_regex -- filter to apply to the match
403 """
405 if not match:
406 return []
408 if isinstance(match, re.Match):
409 return re.findall(filter_regex, match.group(0))
411 return re.findall(filter_regex, match)
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 ))
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