From b9f64241d6e2f43d1be292b6f63767d12a542db4 Mon Sep 17 00:00:00 2001 From: Philipp Wullstein-Kammler Date: Wed, 18 Sep 2024 14:16:02 +0200 Subject: [PATCH] Add new tool `lobster-coda` Issue: #57 --- lobster-coda | 25 + lobster/tools/coda/__init__.py | 0 lobster/tools/coda/coda.py | 391 ++++++++++++ lobster/tools/coda/parser/constants.py | 67 +++ .../tools/coda/parser/requirements_parser.py | 68 +++ lobster/tools/coda/parser/test_case.py | 409 +++++++++++++ packages/lobster-tool-coda/Makefile | 5 + packages/lobster-tool-coda/README.md | 47 ++ packages/lobster-tool-coda/entrypoints | 1 + packages/lobster-tool-coda/requirements | 0 packages/lobster-tool-coda/setup.py | 72 +++ test-unit/lobster-coda/data/test_case.cpp | 236 ++++++++ .../lobster-coda/data/test_coda-1.config | 21 + .../lobster-coda/data/test_coda-2.config | 15 + test-unit/lobster-coda/test_coda.py | 554 ++++++++++++++++++ 15 files changed, 1911 insertions(+) create mode 100644 lobster-coda create mode 100644 lobster/tools/coda/__init__.py create mode 100644 lobster/tools/coda/coda.py create mode 100644 lobster/tools/coda/parser/constants.py create mode 100644 lobster/tools/coda/parser/requirements_parser.py create mode 100644 lobster/tools/coda/parser/test_case.py create mode 100644 packages/lobster-tool-coda/Makefile create mode 100644 packages/lobster-tool-coda/README.md create mode 100644 packages/lobster-tool-coda/entrypoints create mode 100644 packages/lobster-tool-coda/requirements create mode 100644 packages/lobster-tool-coda/setup.py create mode 100644 test-unit/lobster-coda/data/test_case.cpp create mode 100644 test-unit/lobster-coda/data/test_coda-1.config create mode 100644 test-unit/lobster-coda/data/test_coda-2.config create mode 100644 test-unit/lobster-coda/test_coda.py diff --git a/lobster-coda b/lobster-coda new file mode 100644 index 00000000..39568395 --- /dev/null +++ b/lobster-coda @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# +# LOBSTER - Lightweight Open BMW Software Traceability Evidence Report +# Copyright (C) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . + +import sys + +from lobster.tools.coda.coda import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lobster/tools/coda/__init__.py b/lobster/tools/coda/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/lobster/tools/coda/coda.py b/lobster/tools/coda/coda.py new file mode 100644 index 00000000..5f33ec58 --- /dev/null +++ b/lobster/tools/coda/coda.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +# +# lobster_coda - Extract C/C++ tracing tags from commands for LOBSTER +# Copyright (C) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public +# License along with this program. If not, see +# . + +import json +import sys +import argparse +import os.path +from copy import copy +from enum import Enum + +from lobster.items import Tracing_Tag, Activity +from lobster.location import File_Reference +from lobster.io import lobster_write +from lobster.tools.coda.parser.constants import LOBSTER_GENERATOR +from lobster.tools.coda.parser.requirements_parser import ParserForRequirements + +OUTPUT = "output" +MARKERS = "markers" +KIND = "kind" + +NAMESPACE_CPP = "cpp" +LANGUAGE_CPP = "C/C++" +FRAMEWORK_CPP_TEST = "cpp_test" +KIND_FUNCTION = "Function" +CB_PREFIX = "CB-#" +MISSING = "Missing" + + +class RequirementTypes(Enum): + REQS = '@requirement' + REQ_BY = '@requiredby' + DEFECT = '@defect' + + +SUPPORTED_REQUIREMENTS = [ + RequirementTypes.REQS.value, + RequirementTypes.REQ_BY.value, + RequirementTypes.DEFECT.value +] + +map_test_type_to_key_name = { + RequirementTypes.REQS.value: 'requirements', + RequirementTypes.REQ_BY.value: 'required_by', + RequirementTypes.DEFECT.value: 'defect_tracking_ids', +} + + +def parse_config_file(file_name: str) -> dict: + """ + Parse the configuration dictionary from given config file. + + The configuration dictionary for coda must contain the OUTPUT key. + Each output configuration dictionary contains a file name as key and + a value dictionary containing the keys MARKERS and KIND. + The supported values for the MARKERS list are specified in + SUPPORTED_REQUIREMENTS. + + Parameters + ---------- + file_name : str + The file name of the coda config file. + + Returns + ------- + dict + The dictionary containing the configuration for coda. + + Raises + ------ + Exception + If the config dict does not contain the required keys + or contains not supported values. + """ + if not os.path.isfile(file_name): + raise ValueError(f'{file_name} is not an existing file!') + + with open(file_name, "r", encoding='utf-8') as file: + config_dict: dict = json.loads(file.read()) + + if OUTPUT not in config_dict.keys(): + raise ValueError(f'Please follow the right config file structure! ' + f'Missing attribute "{OUTPUT}"') + + output_config_dict = config_dict.get(OUTPUT) + + supported_markers = ', '.join(SUPPORTED_REQUIREMENTS) + for output_file, output_file_config_dict in output_config_dict.items(): + if MARKERS not in output_file_config_dict.keys(): + raise ValueError(f'Please follow the right config file structure! ' + f'Missing attribute "{MARKERS}" for output file ' + f'"{output_file}"') + if KIND not in output_file_config_dict.keys(): + raise ValueError(f'Please follow the right config file structure! ' + f'Missing attribute "{KIND}" for output file ' + f'"{output_file}"') + + output_file_marker_list = output_file_config_dict.get(MARKERS) + for output_file_marker in output_file_marker_list: + if output_file_marker not in SUPPORTED_REQUIREMENTS: + raise ValueError(f'"{output_file_marker}" is not a supported ' + f'"{MARKERS}" value ' + f'for output file "{output_file}". ' + f'Supported values are: ' + f'"{supported_markers}"') + + return config_dict + + +def get_test_file_list(file_dir_list: list, extension_list: list) -> list: + """ + Gets the list of test files. + + Given file names are added to the test file list without + validating against the extension list. + From given directory names only file names will be added + to the test file list if their extension matches against + the extension list. + + Parameters + ---------- + file_dir_list : list + A list containing file names and/or directory names + to parse for file names. + extension_list : list + The list of file name extensions. + + Returns + ------- + list + The list of test files + + Raises + ------ + Exception + If the config dict does not contain the required keys + or contains not supported values. + """ + test_file_list = [] + + for file_dir_entry in file_dir_list: + if os.path.isfile(file_dir_entry): + test_file_list.append(file_dir_entry) + elif os.path.isdir(file_dir_entry): + for path, _, files in os.walk(file_dir_entry): + for filename in files: + _, ext = os.path.splitext(filename) + if ext in extension_list: + test_file_list.append(os.path.join(path, filename)) + else: + raise ValueError(f'"{file_dir_entry}" is not a file or directory.') + + if len(test_file_list) == 0: + raise ValueError(f'"{file_dir_list}" does not contain any test file.') + + return test_file_list + + +def collect_test_cases_from_test_files(test_file_list: list) -> list: + """ + Collects the list of test cases from the given test files. + + Parameters + ---------- + test_file_list : list + The list of test files. + + Returns + ------- + list + The list of test cases. + """ + parser = ParserForRequirements() + test_case_list = parser.collect_test_cases_for_test_files( + test_files=test_file_list + ) + return test_case_list + + +def create_lobster_items_output_dict_from_test_cases( + test_case_list: list, + config_dict: dict) -> dict: + """ + Creates the lobster items dictionary for the given test cases grouped by + configured output. + + Parameters + ---------- + test_case_list : list + The list of test cases. + config_dict : dict + The configuration dictionary. + + Returns + ------- + dict + The lobster items dictionary for the given test cases + grouped by configured output. + """ + prefix = os.getcwd() + lobster_items_output_dict = {} + + no_marker_output_file_name = '' + output_config: dict = config_dict.get(OUTPUT) + marker_output_config_dict = {} + for output_file_name, output_config_dict in output_config.items(): + lobster_items_output_dict[output_file_name] = {} + marker_list = output_config_dict.get(MARKERS) + if isinstance(marker_list, list) and len(marker_list) >= 1: + marker_output_config_dict[output_file_name] = output_config_dict + else: + no_marker_output_file_name = output_file_name + + if no_marker_output_file_name not in lobster_items_output_dict: + lobster_items_output_dict[no_marker_output_file_name] = {} + + for test_case in test_case_list: + function_name: str = test_case.suite_name + file_name = os.path.relpath(test_case.file_name, prefix) + line_nr = int(test_case.docu_start_line) + function_uid = "%s:%s:%u" % (os.path.basename(file_name), + function_name, + line_nr) + tag = Tracing_Tag(NAMESPACE_CPP, function_uid) + loc = File_Reference(file_name, line_nr) + key = tag.key() + + activity = \ + Activity( + tag=tag, + location=loc, + framework=FRAMEWORK_CPP_TEST, + kind=KIND_FUNCTION + ) + + contains_no_tracing_target = True + for output_file_name, output_config_dict in ( + marker_output_config_dict.items()): + tracing_target_list = [] + tracing_target_kind = output_config_dict.get(KIND) + for marker in output_config_dict.get(MARKERS): + if marker not in map_test_type_to_key_name: + continue + + for test_case_marker_value in getattr( + test_case, + map_test_type_to_key_name.get(marker) + ): + if MISSING not in test_case_marker_value: + test_case_marker_value = ( + test_case_marker_value.replace(CB_PREFIX, "")) + tracing_target = Tracing_Tag( + tracing_target_kind, + test_case_marker_value + ) + tracing_target_list.append(tracing_target) + + if len(tracing_target_list) >= 1: + contains_no_tracing_target = False + lobster_item = copy(activity) + for tracing_target in tracing_target_list: + lobster_item.add_tracing_target(tracing_target) + + lobster_items_output_dict.get(output_file_name)[key] = ( + lobster_item) + + if contains_no_tracing_target: + lobster_items_output_dict.get(no_marker_output_file_name)[key] = ( + activity) + + return lobster_items_output_dict + + +def write_lobster_items_output_dict(lobster_items_output_dict: dict): + """ + Write the lobster items to the output. + If the output file name is empty everything is written to stdout. + + Parameters + ---------- + lobster_items_output_dict : dict + The lobster items dictionary grouped by output. + """ + for output_file_name, lobster_items in lobster_items_output_dict.items(): + item_count = len(lobster_items) + if item_count <= 1: + continue + + if output_file_name: + with open(output_file_name, "w", encoding="UTF-8") as output_file: + lobster_write( + output_file, + Activity, + LOBSTER_GENERATOR, + lobster_items.values() + ) + print(f'Written {item_count} lobster items to ' + f'"{output_file_name}".') + + else: + lobster_write( + sys.stdout, + Activity, + LOBSTER_GENERATOR, + lobster_items.values() + ) + print(f'Written {item_count} lobster items to stdout.') + + +def lobster_coda(file_dir_list: list, config_dict: dict): + """ + The main function to parse requirements from comments + for the given list of files and/or directories and write the + created lobster dictionary to the configured outputs. + + Parameters + ---------- + file_dir_list : list + The list of files and/or directories to be parsed + config_dict : dict + The configuration dictionary + """ + test_file_list = \ + get_test_file_list( + file_dir_list=file_dir_list, + extension_list=[".cpp", ".cc", ".c", ".h"] + ) + + test_case_list = \ + collect_test_cases_from_test_files( + test_file_list=test_file_list + ) + + lobster_items_output_dict: dict = \ + create_lobster_items_output_dict_from_test_cases( + test_case_list=test_case_list, + config_dict=config_dict + ) + + write_lobster_items_output_dict( + lobster_items_output_dict=lobster_items_output_dict + ) + + +def main(): + """ + Main function to parse arguments, read configuration + and launch lobster_coda. + """ + ap = argparse.ArgumentParser() + ap.add_argument("files", + nargs="+", + metavar="FILE|DIR") + ap.add_argument("--config-file", + help="path of the config file, it consists of " + "a requirement types as keys and " + "output filenames as value", + required=True, + default=None) + + options = ap.parse_args() + + try: + config_dict = parse_config_file(options.config_file) + + lobster_coda( + file_dir_list=options.files, + config_dict=config_dict + ) + + except ValueError as exception: + ap.error(exception) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/lobster/tools/coda/parser/constants.py b/lobster/tools/coda/parser/constants.py new file mode 100644 index 00000000..9da10202 --- /dev/null +++ b/lobster/tools/coda/parser/constants.py @@ -0,0 +1,67 @@ +import re + +# URLs +GIT_BASEDIR = "/vispiron-swe" +GITHUB_URL = "https://github.com/" +CODEBEMAER_URL = "https://codebeamer.bmwgroup.net/cb" + + +NON_EXISTING_INFO = "---" + +LOBSTER_GENERATOR = "lobster_coda" + +VALID_TEST_MACROS = [ + "TEST", + "TEST_P", + "TEST_F", + "TYPED_TEST", + "TYPED_TEST_P", + "TYPED_TEST_SUITE", + "TEST_P_INSTANCE", + "TEST_F_INSTANCE", +] + +VALID_TESTMETHODS = [ + "TM_EQUIVALENCE", + "TM_PAIRWISE", + "TM_GUESSING", + "TM_BOUNDARY", + "TM_CONDITION", + "TM_REQUIREMENT", + "TM_TABLE", +] + + +# Regex +TEST_CASE_INTRO = re.compile(r"^\s*(" + + "|".join(VALID_TEST_MACROS) + + r")\s*\(") +TEST_CASE_INFO = re.compile( + r"^\s*(" + "|".join(VALID_TEST_MACROS) + + r")\s*\(\s*(?P\w+),\s*(?P\w+)\)" +) + +CODEBEAMER_LINK = CODEBEMAER_URL + "/issue/" +REQUIREMENT = re.compile(r".*[@\\]requirement\s+" + r"([\s*/]*(((CB-#)|({}))\d+)\s*,?)+" + .format(CODEBEAMER_LINK)) +REQUIREMENT_TAG = r"(CB-#\d+)" +REQUIREMENT_TAG_HTTP = ((r"([@\\]requirement(\s+" + r"(CB-#\d+\s+)*({}\d+\s*,?\s*/*\*?)+)+)") + .format(CODEBEAMER_LINK)) +REQUIREMENT_TAG_HTTP_NAMED = r"({}(?P\d+))".format(CODEBEAMER_LINK) +REQUIRED_BY = re.compile(r".*[@\\]requiredby\s+([\s*/]*(\w*::\w+),?\s*)+") +REQUIRED_BY_TAG = r"(\w*::\w+)" +DEFECT = re.compile( + r"(@defect\s+)(((?:(CB-#\d+)|(OCT-#\d+)),?\s*)+)" + + r"(?:///|/)\s+(((?:(CB-#\d+)|(OCT-#\d+)),?\s)+)?" +) +BRIEF = re.compile(r"(@brief\s+)([^@]+)") +VERSION = re.compile(r"(@version\s+)(\d+([,]? \d+)*)+") +OCT_TAG = r"(OCT-#\d+)" +TESTMETHODS = re.compile(r"(@testmethods\s+)([^@]+)") +# unmatch whole testmethod if invalid method is used +# TESTMETHODS = re.compile(r"(@testmethods\s+ +# )((" + "|".join(VALID_TESTMETHODS) + +# ")([,]? (" + "|".join(VALID_TESTMETHODS) + "))*)+") +TEST = re.compile(r"(@test\s+)([^@]+)") diff --git a/lobster/tools/coda/parser/requirements_parser.py b/lobster/tools/coda/parser/requirements_parser.py new file mode 100644 index 00000000..dfbae759 --- /dev/null +++ b/lobster/tools/coda/parser/requirements_parser.py @@ -0,0 +1,68 @@ +""" +This script verifies if the test files of a given +target contains @requirement tag or not +""" +import logging +from pathlib import Path +from typing import List + +from lobster.tools.coda.parser.test_case import TestCase + + +class ParserForRequirements: + + @staticmethod + def collect_test_cases_for_test_files(test_files: List[Path]) -> List: + """ + Parse a list of source files for test cases + + Parameters + ---------- + test_files: List[Path] + Source files to parse + + Returns + ------- + List[TestCase] + List of parsed TestCase + """ + test_cases = [] + + for file in set(test_files): + file_test_cases = ParserForRequirements.collect_test_cases(file) + test_cases.extend(file_test_cases) + + return test_cases + + @staticmethod + def collect_test_cases(file: Path) -> List[TestCase]: + """ + Parse a source file for test cases + + Parameters + ---------- + file: Path + Source file to parse + + Returns + ------- + List[TestCase] + List of parsed TestCase + """ + + try: + with open(file, "r", encoding="UTF-8", errors="ignore") as f: + lines = f.readlines() + + except Exception as e: + logging.error(f"exception {e}") + return [] + + test_cases = [] + + for i in range(0, len(lines)): + test_case = TestCase.try_parse(file, lines, i) + + if test_case: + test_cases.append(test_case) + return test_cases diff --git a/lobster/tools/coda/parser/test_case.py b/lobster/tools/coda/parser/test_case.py new file mode 100644 index 00000000..f0a9a7b2 --- /dev/null +++ b/lobster/tools/coda/parser/test_case.py @@ -0,0 +1,409 @@ +import re +from typing import List + +from lobster.tools.coda.parser.constants import ( + NON_EXISTING_INFO, TEST_CASE_INFO, REQUIREMENT_TAG, + REQUIREMENT_TAG_HTTP, REQUIRED_BY, REQUIREMENT, + REQUIREMENT_TAG_HTTP_NAMED, REQUIRED_BY_TAG, + OCT_TAG, TEST, BRIEF, TESTMETHODS, VALID_TESTMETHODS, + VERSION, TEST_CASE_INTRO, DEFECT) + + +class TestCase: + """ + Class to represent a test case. + + In case of a c/c++ file a test case is considered + to be the combination of a gtest macro, e.g. + TEST(TestSuite, TestName) and its corresponding doxygen + style documentation. + The documentation is assumed to contain references to + requirements and related test cases. + + Limitations on the tag usage: + - @requirement, @requiredby & @defect can be used multiple + times in the test documentation and can be written on + multiple lines + - @brief, @test, @testmethods and @version can be written + on multiple lines but only used once as tag + """ + + def __init__(self, file: str, lines: List[str], start_idx: int): + # File_name where the test case is located + self.file_name = file + # TestSuite from TEST(TestSuite, TestName) + self.suite_name = NON_EXISTING_INFO + # TestName from TEST(TestSuite, TestName) + self.test_name = NON_EXISTING_INFO + # First line of the doxygen style doc for the test case + self.docu_start_line = 1 + # Last line of the doxygen style + self.docu_end_line = 1 + # First line of the implementation (typically the line + # TEST(TestSuite, TestName)) + self.definition_start_line = ( + 1 + ) + # Last line of the implementation + self.definition_end_line = 1 + # List of @requirement values + self.requirements = [] + # List of @requiredby values + self.required_by = [] + # List of @defect values + self.defect_tracking_ids = [] + # Content of @version + self.version_id = [] + # Content of @test + self.test = "" + # Content of @testmethods + self.testmethods = [] + # Content of @brief + self.brief = "" + + self._set_test_details(lines, start_idx) + + def _set_test_details(self, lines, start_idx) -> None: + """ + Parse the given range of lines for a valid test case. + Missing information are replaced by placeholders. + + file -- path to file that the following lines belong to + lines -- lines to parse + start_idx -- index into lines where to start parsing + """ + self.def_end = self._definition_end(lines, start_idx) + + src = [line.strip() for line in lines[start_idx : self.def_end]] + src = "".join(src) + self._set_test_and_suite_name(src) + + self.docu_range = self.get_range_for_doxygen_comments(lines, start_idx) + self.docu_start_line = self.docu_range[0] + 1 + self.docu_end_line = self.docu_range[1] + self.definition_start_line = start_idx + 1 + self.definition_end_line = self.def_end + + if self.docu_range[0] == self.docu_range[1]: + self.docu_start_line = self.docu_range[0] + 1 + self.docu_end_line = self.docu_start_line + + self.docu_lines = [line.strip() for line in + lines[self.docu_range[0]: self.docu_range[1]]] + self.docu_lines = " ".join(self.docu_lines) + self._set_base_attributes() + + def _definition_end(self, lines, start_idx, char=["{", "}"]) -> int: + """ + Function to find the last line of test case definition, + i.e. the closing brace. + + lines -- lines to parse + start_idx -- index into lines where to start parsing + """ + nbraces = 0 + while start_idx < len(lines): + for character in lines[start_idx]: + if character == char[0]: + nbraces = nbraces + 1 + + if character == char[1]: + nbraces = nbraces - 1 + if nbraces == 0: + return start_idx + 1 + + start_idx = start_idx + 1 + return -1 + + def _set_test_and_suite_name(self, src) -> None: + match = TEST_CASE_INFO.search(src) + + if match: + self.test_name = match.groupdict().get("test_name") + self.suite_name = match.groupdict().get("suite_name") + + def _set_base_attributes(self) -> None: + self._get_requirements_from_docu_lines( + REQUIREMENT, + REQUIREMENT_TAG, + REQUIREMENT_TAG_HTTP, + REQUIREMENT_TAG_HTTP_NAMED + ) + + self.required_by = self._get_require_tags( + REQUIRED_BY.search(self.docu_lines), + REQUIRED_BY_TAG + ) + + defect_found = DEFECT.search(self.docu_lines) + if defect_found: + defect_tracking_cb_ids = self._get_require_tags( + defect_found, + REQUIREMENT_TAG + ) + cb_list = sorted( + [ + defect_tracking_id.strip("CB-#") + for defect_tracking_id in defect_tracking_cb_ids + ] + ) + defect_tracking_oct_ids = self._get_require_tags( + defect_found, + OCT_TAG + ) + oct_list = sorted( + [ + defect_tracking_id.strip("CB-#") + for defect_tracking_id in defect_tracking_oct_ids + ] + ) + self.defect_tracking_ids = cb_list + oct_list + self.version_id = self._get_version_tag() + self.test = self._add_multiline_attribute(TEST) + self.testmethods = self._get_testmethod_tag() + self.brief = self._add_multiline_attribute(BRIEF) + + def _get_requirements_from_docu_lines(self, + general_pattern, + tag, tag_http, + tag_http_named): + """ + Function to search for requirements from docu lines + + general_pattern -- pattern to search for requirements + tag -- CB-# tag pattern to be searched in docu + tag_http -- http pattern to be searched in docu + tag_http_named -- named http pattern to be searched in docu + """ + search_result = general_pattern.search(self.docu_lines) + if search_result is None: + return + else: + self.requirements = self._get_require_tags(search_result, tag) + http_requirements = self._get_require_tags(search_result, tag_http) + for requirements_listed_behind_one_tag in http_requirements: + for requirement in requirements_listed_behind_one_tag: + requirement_uri = self._get_uri_from_requirement_detection( + requirement, + tag_http_named + ) + self._add_new_requirement_to_requirement_list( + self, + requirement_uri, + tag_http_named + ) + + def _get_testmethod_tag(self) -> List[str]: + """ + Returns a list of string of valid test methods + If a test method used in the tag is not valid, + the value is dropped from the list + """ + test_methods_list = [] + test_methods = self._add_multiline_attribute(TESTMETHODS) + for test_method in test_methods.split(): + if test_method in VALID_TESTMETHODS: + test_methods_list.append(test_method) + + return test_methods_list + + def _get_version_tag(self) -> List[int]: + """ + Returns a list of versions as int + If the number of version specified is less + than the number of requirement linked, the last version + of the list is added for all requirements + """ + versions = self._add_multiline_attribute(VERSION) + versions_list = versions.split() + if versions_list == []: + versions_list = [float("nan")] + while len(self.requirements) > len(versions_list): + last_value = versions_list[-1] + versions_list.append(last_value) + return versions_list + + def _add_multiline_attribute(self, pattern) -> str: + field = "" + found = pattern.search(self.docu_lines) + if found: + field = (found.group(2).replace("/", " ") + .replace("*", " ") + .replace(",", " ")) if found else "" + field = " ".join(field.split()) + return field + + @staticmethod + def is_line_commented(lines, start_idx) -> bool: + commented = re.compile(r"^\s*(//|\*|/\*)") + if commented.match(lines[start_idx]): + return True + return False + + @staticmethod + def has_no_macro_or_commented(lines, start_idx) -> bool: + return TestCase.has_no_macro_or_commented_general( + lines, + start_idx, + TestCase, + TEST_CASE_INTRO + ) + + @staticmethod + def has_no_macro_or_commented_general(lines, + start_idx, + case, + case_intro) -> bool: + """ + Returns True is the test case does not start with + an INTRO, or if the test case is commented out + """ + line = lines[start_idx].strip() + + # If the test case does not start with a : + # TEST_CASE_INTRO for TestCase + # BENCHMARK_CASE_INTRO for BenchmarkTestCase + if not case_intro.match(line): + return True + # If the test case is commented out + elif case.is_line_commented(lines, start_idx): + return True + return False + + @staticmethod + def is_special_case(lines, test_case) -> bool: + if TestCase.notracing_special_case( + lines, + (test_case.docu_start_line - 1, test_case.docu_end_line) + ): + return True + elif (test_case.suite_name == NON_EXISTING_INFO or + test_case.test_name == NON_EXISTING_INFO): + return True + + return False + + @staticmethod + def try_parse(file, lines, start_idx): + """ + Function to parse the given range of lines for a valid test case. + If a valid test case is found a TestCase object is returned, + otherwise None is returned. + + file -- path to file that the following lines belong to + lines -- lines to parse + start_idx -- index into lines where to start parsing + """ + return TestCase.try_parse_general(file, lines, start_idx, TestCase) + + @staticmethod + def try_parse_general(file, lines, start_idx, case): + """ + Function to parse the given range of lines for a valid general case. + If a valid general case is found a Case object is returned, + otherwise None is returned. + + file -- path to file that the following lines belong to + lines -- lines to parse + start_idx -- index into lines where to start parsing + case -- test case type + """ + if case.has_no_macro_or_commented(lines, start_idx): + # If the test does not follow the convention, None is returned + return None + + tc = case(file, lines, start_idx) + + if case.is_special_case(lines, tc): + return None + + return tc + + @staticmethod + def _get_uri_from_requirement_detection(requirement, tag_http_named): + """ + Function to get uri itself (without @requirement or similar) + + requirement -- requirement candidate + tag_http_named -- http pattern to search for uri in requirement + candidate + """ + if requirement == "": + return None + else: + requirement_uri = re.search(tag_http_named, requirement) + if requirement_uri is None: + return None + else: + return requirement_uri.group() + + @staticmethod + def _add_new_requirement_to_requirement_list( + self, + requirement_uri, + tag_http_named + ): + """ + Function to add new, non-None requirement to requirement + list if not included yet + + requirement_uri -- uri to requirement + tag_http_named -- named http pattern to get requirement + number itself + """ + if requirement_uri is None: + return + else: + named_requirement_number_match = re.match( + tag_http_named, + requirement_uri + ) + requirement_number_dictionary = ( + named_requirement_number_match.groupdict()) + requirement_number = ( + requirement_number_dictionary.get("number")) + requirement_cb = "CB-#" + requirement_number + if requirement_cb not in self.requirements: + self.requirements.append(requirement_cb) + + @staticmethod + def _get_require_tags(match, filter_regex): + """ + Function to filter the given re.match. The + resulting list will only contain the objects + of the match that correspond to the filter. + If the match is empty an empty list is returned. + + match -- re.match object + filter_regex -- filter to apply to the match + """ + + if not match: + return [] + + return re.findall(filter_regex, match.group(0)) + + @staticmethod + def notracing_special_case(lines, the_range): + notracing_tag = "NOTRACING" + return list(filter( + lambda x: notracing_tag in x, + lines[the_range[0]: the_range[1]] + )) + + @staticmethod + def get_range_for_doxygen_comments(lines, index_of_test_definition): + comments = ["///", "//", "/*", "*"] + has_at_least_one_comment = True + index_pointer = index_of_test_definition - 1 + while index_pointer > 0: + if any(x in lines[index_pointer] for x in comments): + index_pointer -= 1 + else: + has_at_least_one_comment = False + break + start_index = index_pointer \ + if has_at_least_one_comment \ + else index_pointer + 1 + doxygen_comments_line_range = (start_index, index_of_test_definition) + return doxygen_comments_line_range diff --git a/packages/lobster-tool-coda/Makefile b/packages/lobster-tool-coda/Makefile new file mode 100644 index 00000000..20d572c7 --- /dev/null +++ b/packages/lobster-tool-coda/Makefile @@ -0,0 +1,5 @@ +package: + rm -rf lobster + mkdir -p lobster/tools + cp -Rv $(LOBSTER_ROOT)/lobster/tools/coda lobster/tools + @python3 setup.py sdist bdist_wheel diff --git a/packages/lobster-tool-coda/README.md b/packages/lobster-tool-coda/README.md new file mode 100644 index 00000000..71d914de --- /dev/null +++ b/packages/lobster-tool-coda/README.md @@ -0,0 +1,47 @@ +# LOBSTER + +The **L**ightweight **O**pen **B**MW **S**oftware **T**raceability +**E**vidence **R**eport allows you to demonstrate software traceability +and requirements coverage, which is essential for meeting standards +such as ISO 26262. + +This package contains a tool extract tracing tags from ISO C or C++ +source code. This tool is also extracting configurable markers/ test-types +from the provided comments in cpp files + +## Tools + +* `lobster-coda`: Extract requirements with dynamic refrences + from comments. + +## Usage + +This tool supports C/C++ code. + +For this you can provide some cpp file with these comments: + +```cpp +/** + * @requirement CB-#1111, CB-#2222, + * CB-#3333 + * @requirement CB-#4444 CB-#5555 + * CB-#6666 + */ +TEST(RequirementTagTest1, RequirementsAsMultipleComments) {} +``` +You can also provide a config-file which determines which markers +should be extracted in which files: + +```config +{ +"@requirement": "unit_tests.lobster", +} + ``` + + +## Copyright & License information + +The copyright holder of LOBSTER is the Bayerische Motoren Werke +Aktiengesellschaft (BMW AG), and LOBSTER is published under the [GNU +Affero General Public License, Version +3](https://github.com/bmw-software-engineering/lobster/blob/main/LICENSE.md). diff --git a/packages/lobster-tool-coda/entrypoints b/packages/lobster-tool-coda/entrypoints new file mode 100644 index 00000000..4c68f430 --- /dev/null +++ b/packages/lobster-tool-coda/entrypoints @@ -0,0 +1 @@ +lobster-coda = lobster.tools.coda.coda:main \ No newline at end of file diff --git a/packages/lobster-tool-coda/requirements b/packages/lobster-tool-coda/requirements new file mode 100644 index 00000000..e69de29b diff --git a/packages/lobster-tool-coda/setup.py b/packages/lobster-tool-coda/setup.py new file mode 100644 index 00000000..8d35f1f1 --- /dev/null +++ b/packages/lobster-tool-coda/setup.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 + +import re +import sys +import setuptools + +from lobster import version + +gh_root = "https://github.com" +gh_project = "bmw-software-engineering/lobster" + +with open("README.md", "r") as fd: + long_description = fd.read() + +with open("requirements", "r") as fd: + package_requirements = [line + for line in fd.read().splitlines() + if line.strip()] +package_requirements.append("bmw-lobster-core>=%s" % version.LOBSTER_VERSION) +with open("entrypoints", "r") as fd: + entrypoints = [line + for line in fd.read().splitlines() + if line.strip()] + +# For the readme to look right on PyPI we need to translate any +# relative links to absolute links to github. +fixes = [] +for match in re.finditer(r"\[(.*)\]\((.*)\)", long_description): + if not match.group(2).startswith("http"): + fixes.append((match.span(0)[0], match.span(0)[1], + "[%s](%s/%s/blob/main/%s)" % (match.group(1), + gh_root, + gh_project, + match.group(2)))) + +for begin, end, text in reversed(fixes): + long_description = (long_description[:begin] + + text + + long_description[end:]) + +project_urls = { + "Bug Tracker" : "%s/%s/issues" % (gh_root, gh_project), + "Documentation" : "%s/pages/%s/" % (gh_root, gh_project), + "Source Code" : "%s/%s" % (gh_root, gh_project), +} + +setuptools.setup( + name="bmw-lobster-tool-coda", + version=version.LOBSTER_VERSION, + author="Bayerische Motoren Werke Aktiengesellschaft (BMW AG)", + author_email="philipp.wullstein-kammler@bmw.de", + description="LOBSTER Tool for ISO C/C++", + long_description=long_description, + long_description_content_type="text/markdown", + url=project_urls["Source Code"], + project_urls=project_urls, + license="GNU Affero General Public License v3", + packages=["lobster.tools.coda"], + install_requires=package_requirements, + python_requires=">=3.7, <4", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", + "Topic :: Documentation", + "Topic :: Software Development", + ], + entry_points={ + "console_scripts": entrypoints, + }, +) diff --git a/test-unit/lobster-coda/data/test_case.cpp b/test-unit/lobster-coda/data/test_case.cpp new file mode 100644 index 00000000..71b8d5a8 --- /dev/null +++ b/test-unit/lobster-coda/data/test_case.cpp @@ -0,0 +1,236 @@ +/** ensure all desired test macros are parsed */ + +TEST_P_INSTANCE(TestMacrosTest, TestPInstance) {} +TEST(TestMacrosTest, TestTest) {} +TEST_F(TestMacrosTest1, TestTestF) {} +TEST_P(TestMacrosTest1, TestTestP) {} +TYPED_TEST(TestMacrosTest2, TestTypedTest) {} +TYPED_TEST_P(TestMacrosTest2, TestTypedTestP) {} +TYPED_TEST_SUITE(TestMacrosTest2, TestTypedTestSuite) {} +TEST_F_INSTANCE(TestMacrosTest3, TestFInstance) {} + +/** ensure test implementation is correctly parsed */ + +TEST( + ImplementationTest, + TestMultiLine +) {} + +TEST(ImplementationTest, EmptyImplementation) {} + +TEST(ImplementationTest, ImplementationMultipleLines) { + EXPECT_EQ(true, DummyFunctionForValidCondition()); +} + +TEST(ImplementationTest, MultipleLinesWithComments) +{ + // Some comments + EXPECT_EQ(true, DummyFunctionForValidCondition()); + // Some other comments +} + +/** ensure test tag is correctly parsed */ + +/// @test foo1 +TEST(TestTagTest, TestTagInOnline) {} + +/// +/// @test foo2 +TEST(TestTagTest, TestTagPrecededByComment) {} + +/// @test foo3 +/// +TEST(TestTagTest, TestTagFollowedByComment) {} + +/// +/// @test foo4 +/// +TEST(TestTagTest, TestTagWithCommentsAround) {} + +/// @test lorem ipsum +TEST(TestTagTest, TestTagAsText) {} + +/** ensure brief are parsed correctly */ + +/// @brief Some nasty bug1 +TEST(BriefTagTest, BriefTagInOnline) {} + +/// @brief This is a brief field +/// with a long description +TEST(BriefTagTest, BriefTagMultipleLines) {} + +/** ensure requirement tags are parse correctly */ + +/// @requirement CB-#0815 +TEST(RequirementTagTest, Requirement) {} + +/** @requirement CB-#0815 CB-#0816 */ +TEST(RequirementTagTest1, RequirementAsOneLineComments) {} + +/** + * @requirement CB-#0815 CB-#0816 + */ +TEST(RequirementTagTest1, RequirementAsComments) {} + +/** + * @requirement CB-#0815, CB-#0816, + * CB-#0817 + * @requirement CB-#0818 CB-#0819 + * CB-#0820 + */ +TEST(RequirementTagTest1, RequirementsAsMultipleComments) {} + +/// +/// @requirement https://codebeamer.bmwgroup.net/cb/issue/0815 +/// +TEST(RequirementTagTest2, URLRequirement) {} + +/// +/// @requirement https://codebeamer.bmwgroup.net/cb/issue/0815, +/// https://codebeamer.bmwgroup.net/cb/issue/0816 +/// +TEST(RequirementTagTest2, URLRequirementsCommaSeparated) {} + +/** + * @requirement https://codebeamer.bmwgroup.net/cb/issue/0815 + * https://codebeamer.bmwgroup.net/cb/issue/0816 + */ +TEST(RequirementTagTest2, URLRequirementsAsCommentsSpaceSeparated) {} + +/** + * @requirement https://codebeamer.bmwgroup.net/cb/issue/0815, https://codebeamer.bmwgroup.net/cb/issue/0816 + * @requirement https://codebeamer.bmwgroup.net/cb/issue/0817 + * https://codebeamer.bmwgroup.net/cb/issue/0818 + */ +TEST(RequirementTagTest2, MultipleURLRequirements) {} + +/// +/// @requirement https://codebeamer.bmwgroup.net/cb/issue/0815 +/// @requirement CB-#0816 +/// +TEST(RequirementTagTest3, MixedRequirements) {} + +/// +/// @requirement something_arbitrary +/// +TEST(RequirementTagTest4, InvalidRequirement) {} + +/// +/// @requirement +/// +TEST(RequirementTagTest4, MissingRequirementReference) {} + +/** ensure required-by tags are parsed correctly */ + +/// +/// @requiredby FOO0::BAR0 +/// +TEST(RequirementByTest1, RequiredByWithAt) {} + +/// +/// @requiredby FOO0::BAR0, FOO1::BAR1 +/// +TEST(RequirementByTest1, MultipleRequiredByCommaSeparated) {} + +/** + * @requiredby FOO0::BAR0, FOO1::BAR1, + * FOO2::BAR2 + * @requiredby FOO3::BAR3 FOO4::BAR4, + * FOO5::BAR5 + * @requiredby FOO6::BAR6 FOO7::BAR7 + * FOO8::BAR8 + */ +TEST(RequirementByTest1, MultipleRequiredByAsComments) {} + +/// @test lorem ipsum +/// @requiredby FOO0::BAR0, +/// FOO1::BAR1, +/// FOO2::BAR2, +/// FOO3::BAR3, +/// FOO4::BAR4, +/// FOO5::BAR5, +/// FOO6::BAR6, +/// FOO7::BAR7, +/// FOO8::BAR8 +/// +TEST(RequirementByTest2, RequiredByWithNewLines) {} + +/** ensure testmethods are parsed correctly */ + +/// @testmethods TM_REQUIREMENT +TEST(TestMethodsTagTest, TestMethod) {} + +/** + * @testmethods TM_PAIRWISE TM_BOUNDARY + */ +TEST(TestMethodsTagTest2, TestMethodAsCommentsSpaceSeparated) {} + +/// @testmethods TM_REQUIREMENT, TM_EQUIVALENCE +TEST(TestMethodsTagTest2, TestMethodAsCommentsCommaSeparated) {} + +// /// @testmethods TM_REQUIREMENT, TM_EQUIVALENCE +// /// @testmethods TM_BOUNDARY, TM_CONDITION +// TEST(TestMethodsTagTest2, TestMethodAsCommentsMultipleLines) {} + +/// +/// @testmethods something_arbitrary +/// +TEST(TestMethodsTagTest3, InvalidTestMethod) {} + +/// +/// @testmethods +/// +TEST(TestMethodsTagTest4, MissingTestMethod) {} + +/** ensure version are parsed correctly */ + +/// @version 1 +TEST(VersionTagTest, VersionTagTestInOnline) {} + +/// @version 1, 42 +TEST(VersionTagTest, MultipleVersionTagTestInOnline) {} + +/// @requirement CB-#0815 +/// @version 12, 70 +TEST(VersionTagTest, MoreVersionsThanRequirements) {} + +/// @requirement CB-#0815, CB-#0816 +/// @version 28 +TEST(VersionTagTest, MoreRequirementsThanVersions) {} + +/// @requirement CB-#123 CB-#456 +/// @version 28 99 +TEST(VersionTagTest, VersionSpaceSeparated) {} + +/** ensure everything is parsed correctly at once */ + +/// +/// @test foo +/// @brief this test tests something +/// @version 42, 2 +/// @requirement CB-#0815, CB-#0816 +/// @requiredby FOO0::BAR0 +/// @testmethods TM_BOUNDARY, TM_REQUIREMENT +/// +TEST(AllTogetherTest, ImplementationMultipleLines) { + EXPECT_EQ(true, DummyFunctionForValidCondition()); +} + +/** + * commented test cases + */ +// TEST(LayoutTest1, SingleComment){} +/* TEST(LayoutTest2, InlineComment){} */ +/* + * TEST(LayoutTest2, Comment) {} + */ + +/** + * invalid test cases + * the following tests should not be parsed + * as valid test cases + */ +TEST(InvalidTest1,) {} +TEST(, InvalidTest2) {} +TEST(,) {} +TEST() {} \ No newline at end of file diff --git a/test-unit/lobster-coda/data/test_coda-1.config b/test-unit/lobster-coda/data/test_coda-1.config new file mode 100644 index 00000000..5fd458b4 --- /dev/null +++ b/test-unit/lobster-coda/data/test_coda-1.config @@ -0,0 +1,21 @@ +{ + "output": { + "component_tests.lobster" : + { + "markers": ["@requirement"], + "kind": "req" + }, + "unit_tests.lobster" : + { + "markers": ["@requiredby"], + "kind": "req" + }, + "other_tests.lobster" : + { + "markers": [], + "kind": "" + } + } +} + + \ No newline at end of file diff --git a/test-unit/lobster-coda/data/test_coda-2.config b/test-unit/lobster-coda/data/test_coda-2.config new file mode 100644 index 00000000..1b532232 --- /dev/null +++ b/test-unit/lobster-coda/data/test_coda-2.config @@ -0,0 +1,15 @@ +{ + "output": { + "component_tests.lobster" : + { + "markers": ["@requirement", "@requiredby"], + "kind": "req" + }, + "other_tests.lobster" : + { + "markers": [], + "kind": "" + } + } +} + \ No newline at end of file diff --git a/test-unit/lobster-coda/test_coda.py b/test-unit/lobster-coda/test_coda.py new file mode 100644 index 00000000..530d2e7d --- /dev/null +++ b/test-unit/lobster-coda/test_coda.py @@ -0,0 +1,554 @@ +import os +import unittest +from pathlib import Path + +from lobster.tools.coda.coda import * +from lobster.tools.coda.parser.requirements_parser import ParserForRequirements + + +class LobsterCodaTests(unittest.TestCase): + + def setUp(self): + self.other_test_lobster_file = 'other_tests.lobster' + self.unit_test_lobster_file = 'unit_tests.lobster' + self.component_test_lobster_file = 'component_tests.lobster' + self.test_fake_dir = str(Path('./not_existing')) + self.test_data_dir = str(Path('./data')) + self.test_case_file = str(Path('./data', 'test_case.cpp')) + self.test_config_1 = str(Path('./data', 'test_coda-1.config')) + self.test_config_2 = str(Path('./data', 'test_coda-2.config')) + + self.req_test_type = [RequirementTypes.REQS.value] + self.req_by_test_type = [RequirementTypes.REQ_BY.value] + self.all_markers_data = { + MARKERS: [RequirementTypes.REQS.value, RequirementTypes.REQ_BY.value], + KIND: "req" + } + self.output_file_name = f'{LOBSTER_GENERATOR}_{os.path.basename(self.test_case_file)}' + self.output_file_name = self.output_file_name.replace('.', '_') + self.output_file_name += '.lobster' + + self.output_fake_file_name = f'{LOBSTER_GENERATOR}_{os.path.basename(self.test_fake_dir)}' + self.output_fake_file_name = self.output_fake_file_name.replace('.', '_') + self.output_fake_file_name += '.lobster' + + self.output_data_file_name = f'{LOBSTER_GENERATOR}_{os.path.basename(self.test_data_dir)}' + self.output_data_file_name = self.output_data_file_name.replace('.', '_') + self.output_data_file_name += '.lobster' + + self.output_file_names = [self.output_file_name, self.output_fake_file_name, + self.output_data_file_name, self.unit_test_lobster_file, + self.component_test_lobster_file, self.other_test_lobster_file] + + def test_parse_config_file_with_two_markers_for_two_outputs(self): + config_dict = parse_config_file(self.test_config_1) + self.assertIsNotNone(config_dict) + self.assertIsInstance(config_dict, dict) + self.assertEqual(1, len(config_dict)) + self.assertTrue(OUTPUT in config_dict.keys()) + + output_config_dict = config_dict.get(OUTPUT) + self.assertIsNotNone(output_config_dict) + self.assertIsInstance(output_config_dict, dict) + self.assertEqual(3, len(output_config_dict)) + self.assertTrue(self.component_test_lobster_file in output_config_dict.keys()) + self.assertTrue(self.unit_test_lobster_file in output_config_dict.keys()) + self.assertTrue(self.other_test_lobster_file in output_config_dict.keys()) + + component_test_config_dict = output_config_dict.get(self.component_test_lobster_file) + self.assertIsNotNone(component_test_config_dict) + self.assertIsInstance(component_test_config_dict, dict) + self.assertEqual(2, len(component_test_config_dict)) + self.assertTrue(MARKERS in component_test_config_dict.keys()) + self.assertTrue(KIND in component_test_config_dict.keys()) + + component_test_markers_list = component_test_config_dict.get(MARKERS) + self.assertIsNotNone(component_test_markers_list) + self.assertIsInstance(component_test_markers_list, list) + self.assertEqual(1, len(component_test_markers_list)) + self.assertTrue('@requirement' in component_test_markers_list) + + component_test_kind_value = component_test_config_dict.get(KIND) + self.assertIsNotNone(component_test_kind_value) + self.assertIsInstance(component_test_kind_value, str) + self.assertEqual('req', component_test_kind_value) + + unit_test_config_dict = output_config_dict.get(self.unit_test_lobster_file) + self.assertIsNotNone(unit_test_config_dict) + self.assertIsInstance(unit_test_config_dict, dict) + self.assertEqual(2, len(unit_test_config_dict)) + self.assertTrue(MARKERS in unit_test_config_dict.keys()) + self.assertTrue(KIND in unit_test_config_dict.keys()) + + unit_test_markers_list = unit_test_config_dict.get(MARKERS) + self.assertIsNotNone(unit_test_markers_list) + self.assertIsInstance(unit_test_markers_list, list) + self.assertEqual(1, len(unit_test_markers_list)) + self.assertTrue('@requiredby' in unit_test_markers_list) + + unit_test_kind_value = unit_test_config_dict.get(KIND) + self.assertIsNotNone(unit_test_kind_value) + self.assertIsInstance(unit_test_kind_value, str) + self.assertEqual('req', unit_test_kind_value) + + other_test_config_dict = output_config_dict.get(self.other_test_lobster_file) + self.assertIsNotNone(other_test_config_dict) + self.assertIsInstance(other_test_config_dict, dict) + self.assertEqual(2, len(other_test_config_dict)) + self.assertTrue(MARKERS in other_test_config_dict.keys()) + self.assertTrue(KIND in other_test_config_dict.keys()) + + other_test_markers_list = other_test_config_dict.get(MARKERS) + self.assertIsNotNone(other_test_markers_list) + self.assertIsInstance(other_test_markers_list, list) + self.assertEqual(0, len(other_test_markers_list)) + + other_test_kind_value = other_test_config_dict.get(KIND) + self.assertIsNotNone(other_test_kind_value) + self.assertIsInstance(other_test_kind_value, str) + self.assertEqual('', other_test_kind_value) + + def test_parse_config_file_with_two_markers_for_one_output(self): + config_dict = parse_config_file(self.test_config_2) + self.assertIsNotNone(config_dict) + self.assertIsInstance(config_dict, dict) + self.assertEqual(1, len(config_dict)) + self.assertTrue(OUTPUT in config_dict.keys()) + + output_config_dict = config_dict.get(OUTPUT) + self.assertIsNotNone(output_config_dict) + self.assertIsInstance(output_config_dict, dict) + self.assertEqual(2, len(output_config_dict)) + self.assertTrue(self.component_test_lobster_file in output_config_dict.keys()) + self.assertTrue(self.other_test_lobster_file in output_config_dict.keys()) + + component_test_config_dict = output_config_dict.get(self.component_test_lobster_file) + self.assertIsNotNone(component_test_config_dict) + self.assertIsInstance(component_test_config_dict, dict) + self.assertEqual(2, len(component_test_config_dict)) + self.assertTrue(MARKERS in component_test_config_dict.keys()) + self.assertTrue(KIND in component_test_config_dict.keys()) + + component_test_markers_list = component_test_config_dict.get(MARKERS) + self.assertIsNotNone(component_test_markers_list) + self.assertIsInstance(component_test_markers_list, list) + self.assertEqual(2, len(component_test_markers_list)) + self.assertTrue('@requirement' in component_test_markers_list) + self.assertTrue('@requiredby' in component_test_markers_list) + + component_test_kind_value = component_test_config_dict.get(KIND) + self.assertIsNotNone(component_test_kind_value) + self.assertIsInstance(component_test_kind_value, str) + self.assertEqual('req', component_test_kind_value) + + other_test_config_dict = output_config_dict.get(self.other_test_lobster_file) + self.assertIsNotNone(other_test_config_dict) + self.assertIsInstance(other_test_config_dict, dict) + self.assertEqual(2, len(other_test_config_dict)) + self.assertTrue(MARKERS in other_test_config_dict.keys()) + self.assertTrue(KIND in other_test_config_dict.keys()) + + other_test_markers_list = other_test_config_dict.get(MARKERS) + self.assertIsNotNone(other_test_markers_list) + self.assertIsInstance(other_test_markers_list, list) + self.assertEqual(0, len(other_test_markers_list)) + + other_test_kind_value = other_test_config_dict.get(KIND) + self.assertIsNotNone(other_test_kind_value) + self.assertIsInstance(other_test_kind_value, str) + self.assertEqual('', other_test_kind_value) + + def test_get_test_file_list(self): + file_dir_list = [self.test_data_dir] + extension_list = [".cpp", ".cc", ".c", ".h"] + + test_file_list = \ + get_test_file_list( + file_dir_list=file_dir_list, + extension_list=extension_list + ) + + self.assertIsNotNone(test_file_list) + self.assertIsInstance(test_file_list, list) + self.assertEqual(1, len(test_file_list)) + self.assertTrue('data\\test_case.cpp' in test_file_list) + + def test_get_test_file_list_no_file_with_matching_extension(self): + file_dir_list = [self.test_data_dir] + extension_list = [".xyz"] + + with self.assertRaises(Exception) as test_get_test_file_list: + test_file_list = \ + get_test_file_list( + file_dir_list=file_dir_list, + extension_list=extension_list + ) + + self.assertIsNone(test_file_list) + + exception_string = str(test_get_test_file_list.exception) + self.assertEqual(f'"{file_dir_list}" does not contain any test file.', exception_string) + + def test_get_test_file_list_not_existing_file_dir(self): + file_dir_list = [self.test_fake_dir] + extension_list = [".cpp", ".cc", ".c", ".h"] + + with self.assertRaises(Exception) as test_get_test_file_list: + test_file_list = \ + get_test_file_list( + file_dir_list=file_dir_list, + extension_list=extension_list + ) + + self.assertIsNone(test_file_list) + + exception_string = str(test_get_test_file_list.exception) + self.assertEqual(f'"{self.test_fake_dir}" is not a file or directory.', exception_string) + + def test_lobster_coda_single_file(self): + file_dir_list = [self.test_case_file] + + if os.path.exists(self.output_file_name): + os.remove(self.output_file_name) + + file_exists = os.path.exists(self.output_file_name) + self.assertFalse(file_exists) + + config_dict = { + OUTPUT: { + self.output_file_name: { + MARKERS: self.req_test_type, + KIND: "req" + }, + self.other_test_lobster_file: { + MARKERS: [], + KIND: '' + } + } + } + + lobster_coda( + file_dir_list=file_dir_list, + config_dict=config_dict + ) + + file_exists = os.path.exists(self.output_file_name) + self.assertTrue(file_exists) + + def test_lobster_coda_single_directory(self): + file_dir_list = [self.test_data_dir] + + if os.path.exists(self.output_data_file_name): + os.remove(self.output_data_file_name) + + file_exists = os.path.exists(self.output_data_file_name) + self.assertFalse(file_exists) + + config_dict = { + OUTPUT: { + self.output_data_file_name: { + MARKERS: self.req_test_type, + KIND: "req" + }, + self.other_test_lobster_file: { + MARKERS: [], + KIND: '' + } + } + } + + lobster_coda( + file_dir_list=file_dir_list, + config_dict=config_dict + ) + + file_exists = os.path.exists(self.output_data_file_name) + self.assertTrue(file_exists) + + def test_lobster_coda_not_existing_file_dir(self): + file_dir_list = [self.test_fake_dir] + + if os.path.exists(self.output_fake_file_name): + os.remove(self.output_fake_file_name) + + file_exists = os.path.exists(self.output_fake_file_name) + self.assertFalse(file_exists) + + config_dict = { + OUTPUT: { + self.output_file_name: { + MARKERS: self.req_test_type, + KIND: "req" + } + } + } + + with self.assertRaises(Exception) as test_lobster_coda: + lobster_coda( + file_dir_list=file_dir_list, + config_dict=config_dict + ) + + exception_string = str(test_lobster_coda.exception) + self.assertEqual(f'"{self.test_fake_dir}" is not a file or directory.', exception_string) + + file_exists = os.path.exists(self.output_fake_file_name) + self.assertFalse(file_exists) + + def test_lobster_coda_separate_output_config(self): + file_dir_list = [self.test_case_file] + config_dict: dict = parse_config_file(self.test_config_1) + + lobster_coda( + file_dir_list=file_dir_list, + config_dict=config_dict + ) + + self.assertEqual(os.path.exists(self.unit_test_lobster_file), True) + + with open(self.unit_test_lobster_file, "r") as unit_test_file: + unit_test_lobster_file_dict = json.loads(unit_test_file.read()) + + unit_test_lobster_items = unit_test_lobster_file_dict.get('data') + self.assertIsNotNone(unit_test_lobster_items) + self.assertIsInstance(unit_test_lobster_items, list) + self.assertEqual(5, len(unit_test_lobster_items)) + + # just check a few refs from the written unit test lobster items + expected_unit_test_refs_dicts = { + "cpp test_case.cpp:RequirementByTest1:130": + ["req FOO0::BAR0", "req FOO1::BAR1"], + "cpp test_case.cpp:RequirementByTest1:135": + ["req FOO0::BAR0", "req FOO1::BAR1", "req FOO2::BAR2", "req FOO3::BAR3", "req FOO4::BAR4", + "req FOO5::BAR5", "req FOO6::BAR6", "req FOO7::BAR7", "req FOO8::BAR8"] + } + + for lobster_item in unit_test_lobster_items: + self.assertIsNotNone(lobster_item) + self.assertIsInstance(lobster_item, dict) + tag = lobster_item.get('tag') + refs = lobster_item.get('refs') + self.assertIsInstance(refs, list) + if tag in expected_unit_test_refs_dicts.keys(): + expected_refs = expected_unit_test_refs_dicts.get(tag) + self.assertListEqual(expected_refs, refs) + + self.assertEqual(os.path.exists(self.component_test_lobster_file), True) + + with open(self.component_test_lobster_file, "r") as component_test_file: + component_test_lobster_file_dict = json.loads(component_test_file.read()) + + component_test_lobster_items = component_test_lobster_file_dict.get('data') + self.assertIsNotNone(component_test_lobster_items) + self.assertIsInstance(component_test_lobster_items, list) + self.assertEqual(13, len(component_test_lobster_items)) + + # just check a few refs from the written component test lobster items + expected_component_test_refs_dicts = { + "cpp test_case.cpp:RequirementTagTest1:70": + ["req 0815", "req 0816"], + "cpp test_case.cpp:RequirementTagTest1:75": + ["req 0815", "req 0816", "req 0817", "req 0818", "req 0819", "req 0820"] + } + + for lobster_item in component_test_lobster_items: + self.assertIsNotNone(lobster_item) + self.assertIsInstance(lobster_item, dict) + tag = lobster_item.get('tag') + refs = lobster_item.get('refs') + self.assertIsInstance(refs, list) + if tag in expected_component_test_refs_dicts.keys(): + expected_refs = expected_component_test_refs_dicts.get(tag) + self.assertListEqual(expected_refs, refs) + + self.assertEqual(os.path.exists(self.other_test_lobster_file), True) + + with open(self.other_test_lobster_file, "r") as other_test_file: + other_test_lobster_file_dict = json.loads(other_test_file.read()) + + other_test_lobster_items = other_test_lobster_file_dict.get('data') + self.assertIsNotNone(other_test_lobster_items) + self.assertIsInstance(other_test_lobster_items, list) + self.assertEqual(28, len(other_test_lobster_items)) + + def test_test_case_parsing(self): + """ + Verify that the test case parsing is working correctly + The whole TestCase class is tested as one since the test_file contains all possible + variant of an allowed test case implementation + """ + # fmt: off + expect = [ + # Verify that test macros, test suite, test name and documentation comments are correctly parsed + {"suite": "TestMacrosTest", "test_name": "TestPInstance", + "docu_start": 3, "docu_end": 3, "def_start": 3, "def_end": 3}, + {"suite": "TestMacrosTest", "test_name": "TestTest", + "docu_start": 4, "docu_end": 4, "def_start": 4, "def_end": 4}, + {"suite": "TestMacrosTest1", "test_name": "TestTestF", + "docu_start": 5, "docu_end": 5, "def_start": 5, "def_end": 5}, + {"suite": "TestMacrosTest1", "test_name": "TestTestP", + "docu_start": 6, "docu_end": 6, "def_start": 6, "def_end": 6}, + {"suite": "TestMacrosTest2", "test_name": "TestTypedTest", + "docu_start": 7, "docu_end": 7, "def_start": 7, "def_end": 7}, + {"suite": "TestMacrosTest2", "test_name": "TestTypedTestP", + "docu_start": 8, "docu_end": 8, "def_start": 8, "def_end": 8}, + {"suite": "TestMacrosTest2", "test_name": "TestTypedTestSuite", + "docu_start": 9, "docu_end": 9, "def_start": 9, "def_end": 9}, + {"suite": "TestMacrosTest3", "test_name": "TestFInstance", + "docu_start": 10, "docu_end": 10, "def_start": 10, "def_end": 10}, + # Verify that test implementation is correctly parsed (def_start, def_end) + {"suite": "ImplementationTest", "test_name": "TestMultiLine", + "docu_start": 14, "docu_end": 14, "def_start": 14, "def_end": 17}, + {"suite": "ImplementationTest", "test_name": "EmptyImplementation", + "docu_start": 19, "docu_end": 19, "def_start": 19, "def_end": 19}, + {"suite": "ImplementationTest", "test_name": "ImplementationMultipleLines", + "docu_start": 21, "docu_end": 21, "def_start": 21, "def_end": 23}, + {"suite": "ImplementationTest", "test_name": "MultipleLinesWithComments", + "docu_start": 25, "docu_end": 25, "def_start": 25, "def_end": 30}, + # Verify that the test tag is correctly parsed + {"suite": "TestTagTest", "test": "foo1", "test_name": "TestTagInOnline"}, + {"suite": "TestTagTest", "test": "foo2", "test_name": "TestTagPrecededByComment"}, + {"suite": "TestTagTest", "test": "foo3", "test_name": "TestTagFollowedByComment"}, + {"suite": "TestTagTest", "test": "foo4", "test_name": "TestTagWithCommentsAround"}, + {"suite": "TestTagTest", "test": "lorem ipsum", "test_name": "TestTagAsText"}, + # Verify that the brief tag is correctly parsed + {"suite": "BriefTagTest", "brief": "Some nasty bug1", "test_name": "BriefTagInOnline"}, + {"suite": "BriefTagTest", "brief": "This is a brief field with a long description", + "test_name": "BriefTagMultipleLines"}, + # Verify that the requirement tags are correctly parsed + {"suite": "RequirementTagTest", "test_name": "Requirement", + "req": ["CB-#0815"]}, + {"suite": "RequirementTagTest1", "test_name": "RequirementAsOneLineComments", + "req": ["CB-#0815", "CB-#0816"]}, + {"suite": "RequirementTagTest1", "test_name": "RequirementAsComments", + "req": ["CB-#0815", "CB-#0816"]}, + {"suite": "RequirementTagTest1", "test_name": "RequirementsAsMultipleComments", + "req": ["CB-#0815", "CB-#0816", "CB-#0817", "CB-#0818", "CB-#0819", "CB-#0820"]}, + {"suite": "RequirementTagTest2", "test_name": "URLRequirement", + "req": ["CB-#0815"]}, + {"suite": "RequirementTagTest2", "test_name": "URLRequirementsCommaSeparated", + "req": ["CB-#0815", "CB-#0816"]}, + {"suite": "RequirementTagTest2", "test_name": "URLRequirementsAsCommentsSpaceSeparated", + "req": ["CB-#0815", "CB-#0816"]}, + {"suite": "RequirementTagTest2", "test_name": "MultipleURLRequirements", + "req": ["CB-#0815", "CB-#0816", "CB-#0817", "CB-#0818"]}, + {"suite": "RequirementTagTest3", "test_name": "MixedRequirements", + "req": ["CB-#0816", "CB-#0815"]}, + {"suite": "RequirementTagTest4", "test_name": "InvalidRequirement", + "req": []}, + {"suite": "RequirementTagTest4", "test_name": "MissingRequirementReference", + "req": []}, + # Verify that the required-by tag is correctly parsed + {"suite": "RequirementByTest1", "test_name": "RequiredByWithAt", + "req_by": ["FOO0::BAR0"]}, + {"suite": "RequirementByTest1", "test_name": "MultipleRequiredByCommaSeparated", + "req_by": ["FOO0::BAR0", "FOO1::BAR1"]}, + {"suite": "RequirementByTest1", "test_name": "MultipleRequiredByAsComments", + "req_by": ["FOO0::BAR0", "FOO1::BAR1", "FOO2::BAR2", "FOO3::BAR3", "FOO4::BAR4", "FOO5::BAR5", + "FOO6::BAR6", "FOO7::BAR7", "FOO8::BAR8"]}, + {"suite": "RequirementByTest2", "test_name": "RequiredByWithNewLines", + "req_by": ["FOO0::BAR0", "FOO1::BAR1", "FOO2::BAR2", "FOO3::BAR3", "FOO4::BAR4", "FOO5::BAR5", + "FOO6::BAR6", "FOO7::BAR7", "FOO8::BAR8"]}, + # Verify that the test methods tag is correctly parsed + {"suite": "TestMethodsTagTest", "test_name": "TestMethod", + "testmethods": ["TM_REQUIREMENT"]}, + {"suite": "TestMethodsTagTest2", "test_name": "TestMethodAsCommentsSpaceSeparated", + "testmethods": ["TM_PAIRWISE", "TM_BOUNDARY"]}, + {"suite": "TestMethodsTagTest2", "test_name": "TestMethodAsCommentsCommaSeparated", + "testmethods": ["TM_REQUIREMENT", "TM_EQUIVALENCE"]}, + # {"suite": "TestMethodsTagTest2", "test_name": "TestMethodAsCommentsMultipleLines", + # "testmethods": ["TM_REQUIREMENT", "TM_EQUIVALENCE", "TM_BOUNDARY", "TM_CONDITION"]}, + # TODO: Not supported yet, to be discussed in DCS-3430 + {"suite": "TestMethodsTagTest3", "test_name": "InvalidTestMethod", "testmethods": []}, + {"suite": "TestMethodsTagTest4", "test_name": "MissingTestMethod", "testmethods": []}, + # Verify that the version tag is correctly parsed + {"suite": "VersionTagTest", "test_name": "VersionTagTestInOnline", "version": ["1"]}, + {"suite": "VersionTagTest", "test_name": "MultipleVersionTagTestInOnline", "version": ["1", "42"]}, + {"suite": "VersionTagTest", "test_name": "MoreVersionsThanRequirements", "version": ["12", "70"], + "req": ["CB-#0815"]}, + {"suite": "VersionTagTest", "test_name": "MoreRequirementsThanVersions", "version": ["28", "28"], + "req": ["CB-#0815", "CB-#0816"]}, + {"suite": "VersionTagTest", "test_name": "VersionSpaceSeparated", "version": ["28", "99"], + "req": ["CB-#123", "CB-#456"]}, + # Verify that all at once is correctly parsed + {"suite": "AllTogetherTest", "test_name": "ImplementationMultipleLines", + "docu_start": 207, "docu_end": 214, "def_start": 215, "def_end": 217, "version": ["42", "2"], + "test": "foo", "brief": "this test tests something", + "req": ["CB-#0815", "CB-#0816"], + "req_by": ["FOO0::BAR0"], "testmethods": ["TM_BOUNDARY", "TM_REQUIREMENT"]} + ] + # fmt: on + + test_cases = ParserForRequirements.collect_test_cases(self.test_case_file) + self.assertEqual(len(test_cases), 45) + + for i in range(0, len(expect)): + self.assertEqual(test_cases[i].file_name, self.test_case_file) + self.assertEqual(test_cases[i].suite_name, expect[i]["suite"]) + self.assertEqual(test_cases[i].test_name, expect[i]["test_name"]) + if "docu_start" in expect[i]: + self.assertEqual( + test_cases[i].docu_start_line, + expect[i]["docu_start"], + "docu_start does not match for test_name " + test_cases[i].test_name, + ) + self.assertEqual( + test_cases[i].docu_end_line, + expect[i]["docu_end"], + "docu_end does not match for test_name " + test_cases[i].test_name, + ) + self.assertEqual( + test_cases[i].definition_start_line, + expect[i]["def_start"], + "def_start does not match for test_name " + test_cases[i].test_name, + ) + self.assertEqual( + test_cases[i].definition_end_line, + expect[i]["def_end"], + "def_end does not match for test_name " + test_cases[i].test_name, + ) + if "req" in expect[i]: + self.assertEqual( + test_cases[i].requirements, + expect[i]["req"], + "req does not match for test_name " + test_cases[i].test_name, + ) + if "req_by" in expect[i]: + self.assertEqual( + test_cases[i].required_by, + expect[i]["req_by"], + "req_by does not match for test_name " + test_cases[i].test_name, + ) + if "version" in expect[i]: + self.assertEqual( + test_cases[i].version_id, + expect[i]["version"], + "version does not match for test_name " + test_cases[i].test_name, + ) + if "test" in expect[i]: + self.assertEqual( + test_cases[i].test, + expect[i]["test"], + "test does not match for test_name " + test_cases[i].test_name, + ) + if "testmethods" in expect[i]: + self.assertEqual( + test_cases[i].testmethods, + expect[i]["testmethods"], + "testmethods does not match for test_name " + test_cases[i].test_name, + ) + if "brief" in expect[i]: + self.assertEqual( + test_cases[i].brief, + expect[i]["brief"], + "brief does not match for test_name " + test_cases[i].test_name, + ) + + def tearDown(self): + for output_file in self.output_file_names: + if os.path.exists(output_file): + os.remove(output_file) + + +if __name__ == '__main__': + unittest.main()