Coverage for lobster/tools/cpptest/parser/test_case.py: 93%
181 statements
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 09:51 +0000
« prev ^ index » next coverage.py v7.10.2, created at 2025-08-06 09:51 +0000
1import re
2from typing import List
4from lobster.tools.cpptest.parser.constants import Constants
7class TestCase:
8 """
9 Class to represent a test case.
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.
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 """
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 = ""
61 self._set_test_details(lines, start_idx)
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.
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)
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)
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
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
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()
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.
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
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
113 start_idx = start_idx + 1
114 return -1
116 def _set_test_and_suite_name(self, src) -> None:
117 match = self.constants.TEST_CASE_INFO.search(src)
119 if match:
120 self.test_name = match.groupdict().get("test_name")
121 self.suite_name = match.groupdict().get("suite_name")
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 )
131 self.required_by = self._get_require_tags(
132 self.constants.REQUIRED_BY.search(self.docu_lines),
133 self.constants.REQUIRED_BY_TAG
134 )
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)
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
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))
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 )
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)
210 return test_methods_list
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
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
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
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 )
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()
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
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
286 return False
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.
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 )
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.
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
323 tc = case(file, lines, start_idx, codebeamer_url)
325 if case.is_special_case(lines, tc):
326 return None
328 return tc
330 @staticmethod
331 def _get_uri_from_requirement_detection(requirement, tag_http_named):
332 """
333 Function to get uri itself (without @requirement or similar)
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: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 return None
345 else:
346 return requirement_uri.group()
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
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)
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.
385 match -- re.match object or string
386 filter_regex -- filter to apply to the match
387 """
389 if not match:
390 return []
392 if isinstance(match, re.Match):
393 return re.findall(filter_regex, match.group(0))
395 return re.findall(filter_regex, match)
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 ))
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