diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 49cd17622..23b10ea80 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: - name: Run test targets run: | bazel run --lockfile_mode=error //:ide_support - bazel test --lockfile_mode=error //src/... //score_pytest/... + bazel test --lockfile_mode=error //... - name: Prepare bundled consumer report if: always() diff --git a/scripts_bazel/tests/generate_sourcelinks_cli_test.py b/scripts_bazel/tests/generate_sourcelinks_cli_test.py index f25acc5ab..e05fece8c 100644 --- a/scripts_bazel/tests/generate_sourcelinks_cli_test.py +++ b/scripts_bazel/tests/generate_sourcelinks_cli_test.py @@ -11,17 +11,63 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +# ╓ ╖ +# ║ Some parts are generated by Gemini ║ +# ╙ ╜ + """Tests for generate_sourcelinks_cli.py""" import json -import subprocess import sys from pathlib import Path +import pytest + +import scripts_bazel.generate_sourcelinks_cli +from src.extensions.score_source_code_linker.needlinks import is_metadata + _MY_PATH = Path(__file__).parent -def test_generate_sourcelinks_cli_basic(tmp_path: Path) -> None: +def assert_json_internal_types(input: list[dict[str, str | int]]): + for entry in input: + assert "file" in entry + assert "line" in entry + assert "tag" in entry + assert "need" in entry + assert "full_line" in entry + + assert isinstance(entry["file"], str) + assert isinstance(entry["line"], int) + assert isinstance(entry["tag"], str) + assert isinstance(entry["need"], str) + assert isinstance(entry["full_line"], str) + + +# Unit test generated by Gemini. +@pytest.mark.parametrize( + "input_path, expected_output", + [ + # Case 1: Path starts with "external/" and has a project name + (Path("external/score_docs_as_code+/docs/index.md"), Path("docs/index.md")), + # Case 2: Path does NOT start with "external/" + (Path("src/main.py"), Path("src/main.py")), + # Case 3: Path has "external" elsewhere in the string (should not be removed) + (Path("my_external_data/file.txt"), Path("my_external_data/file.txt")), + # Case 4: Deeply nested path inside an external prefix + (Path("external/repo/subfolder/file.py"), Path("subfolder/file.py")), + # Case 5: Path is exactly "external/" (edge case, returns empty path based on split logic) + (Path("external/"), Path("external")), + ], +) +def test_clean_external_prefix(input_path: Path, expected_output: Path): + output = scripts_bazel.generate_sourcelinks_cli.clean_external_prefix(input_path) + assert output == expected_output + + +def test_generate_sourcelinks_cli_basic( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: """Test basic functionality of generate_sourcelinks_cli.""" # Create a test source file with a traceability tag test_file = tmp_path / "test_source.py" @@ -36,39 +82,116 @@ def some_function(): output_file = tmp_path / "output.json" - # Execute the script - result = subprocess.run( - [ - sys.executable, - _MY_PATH.parent / "generate_sourcelinks_cli.py", - "--output", - str(output_file), - str(test_file), - ], - ) - - assert result.returncode == 0 - assert output_file.exists() + test_args: list[Path | str] = [ + _MY_PATH.parent + / "generate_sourcelinks_cli.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + str(test_file), + ] + monkeypatch.setattr(sys, "argv", test_args) + result = scripts_bazel.generate_sourcelinks_cli.main() + assert result == 0 # Check the output content with open(output_file) as f: data: list[dict[str, str | int]] = json.load(f) assert isinstance(data, list) - assert len(data) > 0 + # The first dictionary has to be metadata + assert len(data) == 2 + assert is_metadata(data[0]) + assert data[0]["repo_name"] == "local_repo" + # hash & url can not be set in this script therefore HAVE to be empty + assert data[0]["hash"] == "" + assert data[0]["url"] == "" # Verify schema of each entry - for entry in data: - assert "file" in entry - assert "line" in entry - assert "tag" in entry - assert "need" in entry - assert "full_line" in entry + assert_json_internal_types(data[1:]) - # Verify types - assert isinstance(entry["file"], str) - assert isinstance(entry["line"], int) - assert isinstance(entry["tag"], str) - assert isinstance(entry["need"], str) - assert isinstance(entry["full_line"], str) + assert data[1]["need"] == "tool_req__docs_arch_types" - assert any(entry["need"] == "tool_req__docs_arch_types" for entry in data) + +def test_generate_sourcelinks_cli_parse_external_module( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + # 1. Create the 'external' directory inside the temp path + external_root = tmp_path / "external" + external_root.mkdir() + + # 2. Create your file inside that 'external' directory + test_file = external_root / "score_baselibs+" / "src" / "source_file1.py" + test_file.parent.mkdir(parents=True) + test_file.write_text("content") + + # 3. Create the path relative to tmp_path so it starts with 'external/' + # Use .relative_to(tmp_path) to get 'external/score_docs_as_code+/...' + test_file.write_text( + """ +# Some code here +# req-Id: tool_req__docs_arch_types +def some_function(): + pass +""" + ) + output_file = tmp_path / "output.json" + monkeypatch.chdir(tmp_path) + relative_test_file = test_file.relative_to(tmp_path) + test_args: list[Path | str] = [ + _MY_PATH.parent + / "generate_sourcelinks_cli.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + str(relative_test_file), + ] + monkeypatch.setattr(sys, "argv", test_args) + result = scripts_bazel.generate_sourcelinks_cli.main() + assert result == 0 + with open(output_file) as f: + data: list[dict[str, str | int]] = json.load(f) + assert isinstance(data, list) + assert len(data) == 2 + # The first dictionary has to be metadata + assert is_metadata(data[0]) + assert data[0]["repo_name"] == "score_baselibs" + # hash & url can not be set in this script therefore HAVE to be empty + assert data[0]["hash"] == "" + assert data[0]["url"] == "" + + # Verify schema of each entry + assert_json_internal_types(data[1:]) + + +def test_generate_sourcelinks_cli_file_not_exists( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + external_root = tmp_path / "external" + external_root.mkdir() + + # 2. Create your file inside that 'external' directory + test_file = external_root / "score_baselibs+" / "src" / "source_file1.py" + test_file.parent.mkdir(parents=True) + test_file.write_text("content") + + # 3. Create the path relative to tmp_path so it starts with 'external/' + # Use .relative_to(tmp_path) to get 'external/score_docs_as_code+/...' + test_file.write_text( + """ +# Some code here +# req-Id: tool_req__docs_arch_types +def some_function(): + pass +""" + ) + output_file = tmp_path / "output.json" + # BY not changing directory (like above) we can FORCE the file to not exists + relative_test_file = test_file.relative_to(tmp_path) + test_args: list[Path | str] = [ + _MY_PATH.parent + / "generate_sourcelinks_cli.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + str(relative_test_file), + ] + monkeypatch.setattr(sys, "argv", test_args) + with pytest.raises(AssertionError): + scripts_bazel.generate_sourcelinks_cli.main() diff --git a/scripts_bazel/tests/merge_sourcelinks_test.py b/scripts_bazel/tests/merge_sourcelinks_test.py index 9f92cfd6c..f20501a3f 100644 --- a/scripts_bazel/tests/merge_sourcelinks_test.py +++ b/scripts_bazel/tests/merge_sourcelinks_test.py @@ -14,27 +14,50 @@ """Tests for merge_sourcelinks.py""" import json -import subprocess +import logging import sys from pathlib import Path +import pytest + +import scripts_bazel.merge_sourcelinks + +LOGGER = logging.getLogger(__name__) + _MY_PATH = Path(__file__).parent -def test_merge_sourcelinks_basic(tmp_path: Path) -> None: +def assert_json_internal_types(input: list[dict[str, str | int]]): + for entry in input: + assert "file" in entry + assert "line" in entry + assert "tag" in entry + assert "need" in entry + assert "full_line" in entry + + assert isinstance(entry["file"], str) + assert isinstance(entry["line"], int) + assert isinstance(entry["tag"], str) + assert isinstance(entry["need"], str) + assert isinstance(entry["full_line"], str) + + +@pytest.fixture +def create_local_json_files(tmp_path: Path) -> tuple[Path, Path, Path]: """Test basic merge functionality.""" # Create test JSON files with correct schema file1 = tmp_path / "links1.json" file1.write_text( json.dumps( [ + {"repo_name": "local_repo", "hash": "", "url": ""}, { "file": "test1.py", "line": 10, "tag": "# req-Id:", "need": "tool_req__docs_arch_types", "full_line": "# req-Id: tool_req__docs_arch_types", - } + }, ] ) ) @@ -43,31 +66,79 @@ def test_merge_sourcelinks_basic(tmp_path: Path) -> None: file2.write_text( json.dumps( [ + {"repo_name": "local_repo", "hash": "", "url": ""}, { "file": "test2.py", "line": 20, "tag": "# req-Id:", "need": "gd_req__req_validity", "full_line": "# req-Id: gd_req__req_validity", - } + }, ] ) ) - output_file = tmp_path / "merged.json" + return file1, file2, output_file + - result = subprocess.run( - [ - sys.executable, - _MY_PATH.parent / "merge_sourcelinks.py", - "--output", - str(output_file), - str(file1), - str(file2), - ], +@pytest.fixture +def create_external_repo_json_files(tmp_path: Path) -> tuple[Path, Path, Path]: + file1 = tmp_path / "links1.json" + file1.write_text( + json.dumps( + [ + { + "repo_name": "score_baselibs", + "url": "https://github.com/eclipse-score/baselibs.git", + "hash": "158fe6a7b791c58f6eac5f7e4662b8db0cf9ac6e", + }, + { + "file": "test1.py", + "line": 10, + "tag": "# req-Id:", + "need": "tool_req__docs_arch_types", + "full_line": "# req-Id: tool_req__docs_arch_types", + }, + ] + ) ) - assert result.returncode == 0 + file2 = tmp_path / "links2.json" + file2.write_text( + json.dumps( + [ + {"repo_name": "local_repo", "hash": "", "url": ""}, + { + "file": "test2.py", + "line": 20, + "tag": "# req-Id:", + "need": "gd_req__req_validity", + "full_line": "# req-Id: gd_req__req_validity", + }, + ] + ) + ) + output_file = tmp_path / "merged.json" + return file1, file2, output_file + + +def test_merge_sourcelinks_basic( + create_local_json_files: tuple[Path, Path, Path], monkeypatch: pytest.MonkeyPatch +): + file1, file2, output_file = create_local_json_files + + test_args: list[Path | str] = [ + _MY_PATH.parent + / "merge_sourcelinks.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + str(file1), + str(file2), + ] + monkeypatch.setattr(sys, "argv", test_args) + result = scripts_bazel.merge_sourcelinks.main() + + assert result == 0 assert output_file.exists() with open(output_file) as f: @@ -76,18 +147,6 @@ def test_merge_sourcelinks_basic(tmp_path: Path) -> None: assert len(data) == 2 # Verify schema of merged entries - for entry in data: - assert "file" in entry - assert "line" in entry - assert "tag" in entry - assert "need" in entry - assert "full_line" in entry - - assert isinstance(entry["file"], str) - assert isinstance(entry["line"], int) - assert isinstance(entry["tag"], str) - assert isinstance(entry["need"], str) - assert isinstance(entry["full_line"], str) # Verify specific entries assert any( @@ -98,3 +157,167 @@ def test_merge_sourcelinks_basic(tmp_path: Path) -> None: entry["need"] == "gd_req__req_validity" and entry["file"] == "test2.py" for entry in data ) + + +def test_merge_sourcelinks_with_one_empty_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + file1 = tmp_path / "links1.json" + file1_text: list[dict[str, str | int]] = [ + {"repo_name": "local_repo", "hash": "", "url": ""}, + { + "file": "test1.py", + "line": 10, + "tag": "# req-Id:", + "need": "tool_req__docs_arch_types", + "full_line": "# req-Id: tool_req__docs_arch_types", + }, + ] + file1.write_text(json.dumps(file1_text)) + file2 = tmp_path / "links2.json" + file2.write_text(json.dumps([])) + output_file = tmp_path / "merged.json" + test_args: list[Path | str] = [ + _MY_PATH.parent + / "merge_sourcelinks.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + str(file1), + str(file2), + ] + monkeypatch.setattr(sys, "argv", test_args) + result = scripts_bazel.merge_sourcelinks.main() + assert result == 0 + with open(output_file) as f: + data: list[dict[str, str | int]] = json.load(f) + assert isinstance(data, list) + assert len(data) == 1 + + # It should only contain info of the NON empty file. + # Empty file should be a no-op + wanted_info: list[dict[str, str | int]] = [ + { + "file": "test1.py", + "line": 10, + "tag": "# req-Id:", + "need": "tool_req__docs_arch_types", + "full_line": "# req-Id: tool_req__docs_arch_types", + "repo_name": "local_repo", # comes from first dict in input + "hash": "", # comes from first dict in input + "url": "", # comes from first dict in input + } + ] + assert data == wanted_info + # Verify schema of merged entries + assert_json_internal_types(data) + assert any( + entry["need"] == "tool_req__docs_arch_types" and entry["file"] == "test1.py" + for entry in data + ) + + +def test_merge_sourcelinks_wrong_schema( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +): + file1 = tmp_path / "links1.json" + file1_text: list[dict[str, str | int]] = [ + { + "file": "test1.py", + "line": 10, + "tag": "# req-Id:", + "need": "tool_req__docs_arch_types", + "full_line": "# req-Id: tool_req__docs_arch_types", + }, + ] + file1.write_text(json.dumps(file1_text)) + output_file = tmp_path / "merged.json" + test_args: list[Path | str] = [ + _MY_PATH.parent + / "merge_sourcelinks.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + str(file1), + ] + monkeypatch.setattr(sys, "argv", test_args) + scripts_bazel.merge_sourcelinks.main() + error_text = ( + f"Unexpected schema in sourcelinks file '{file1}': " + "expected first element to be a metadata dict " + "with a 'repo_name' key. " + ) + assert error_text in caplog.text + + +# Taken from score_source_code_linker.test_helpers +VALID_KNOWN_GOOD = { + "modules": { + "target_sw": { + "score_baselibs": { + "repo": "https://github.com/eclipse-score/baselibs.git", + "hash": "158fe6a7b791c58f6eac5f7e4662b8db0cf9ac6e", + }, + "score_communication": { + "repo": "https://github.com/eclipse-score/communication.git", + "hash": "56448a5589a5f7d3921b873e8127b824a8c1ca95", + }, + }, + "tooling": { + "score_docs_as_code": { + "repo": "https://github.com/eclipse-score/docs-as-code.git", + "hash": "c1207676afe6cafd25c35d420e73279a799515d8", + } + }, + } +} + + +def test_merge_sourcelinks_with_known_good( + tmp_path: Path, + create_external_repo_json_files: tuple[Path, Path, Path], + monkeypatch: pytest.MonkeyPatch, +): + file1, file2, output_file = create_external_repo_json_files + known_good_file = tmp_path / "known_good.json" + known_good_file.write_text(json.dumps(VALID_KNOWN_GOOD)) + + test_args: list[Path | str] = [ + _MY_PATH.parent + / "merge_sourcelinks.py", # sys.argv[0] is always the script name + "--output", + str(output_file), + "--known_good", + str(known_good_file), + str(file1), + str(file2), + ] + monkeypatch.setattr(sys, "argv", test_args) + result = scripts_bazel.merge_sourcelinks.main() + assert result == 0 + assert output_file.exists() + with open(output_file) as f: + data: list[dict[str, str | int]] = json.load(f) + assert isinstance(data, list) + assert len(data) == 2 + assert_json_internal_types(data) + expected_dict1: dict[str, str | int] = { + "file": "test2.py", + "line": 20, + "tag": "# req-Id:", + "need": "gd_req__req_validity", + "full_line": "# req-Id: gd_req__req_validity", + "repo_name": "local_repo", # comes from first dict in input + "hash": "", # comes from first dict in input + "url": "", # comes from first dict in input + } + expected_dict2: dict[str, str | int] = { + "file": "test1.py", + "line": 10, + "tag": "# req-Id:", + "need": "tool_req__docs_arch_types", + "full_line": "# req-Id: tool_req__docs_arch_types", + "repo_name": "score_baselibs", + "url": "https://github.com/eclipse-score/baselibs", # gets filled via known_good + "hash": "158fe6a7b791c58f6eac5f7e4662b8db0cf9ac6e", # gets filled via known_good + } + assert expected_dict1 in data + assert expected_dict2 in data