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
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-26 14:55 +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 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 )
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)
207 return test_methods_list
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
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
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
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 )
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()
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
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
283 return False
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.
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 )
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.
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
320 tc = case(file, lines, start_idx, codebeamer_url)
322 if case.is_special_case(lines, tc):
323 return None
325 return tc
327 @staticmethod
328 def _get_uri_from_requirement_detection(requirement, tag_http_named):
329 """
330 Function to get uri itself (without @requirement or similar)
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()
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
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)
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.
382 match -- re.match object
383 filter_regex -- filter to apply to the match
384 """
386 if not match:
387 return []
389 return re.findall(filter_regex, match.group(0))
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 ))
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