From 616f8411a593a11782d717e4d7c39f8d1e1b7e27 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 22:09:33 +0000 Subject: [PATCH] CodeRabbit Generated Unit Tests: Add unit tests for PR changes --- tests/test_git_submodules.py | 313 ++++++++++++++++++++ tests/test_gitsubproject.py | 378 ++++++++++++++++++++++++ tests/test_metadata_nested.py | 421 +++++++++++++++++++++++++++ tests/test_stdout_reporter_nested.py | 123 ++++++++ tests/test_subproject.py | 23 +- tests/test_util.py | 146 ++++++++++ 6 files changed, 1402 insertions(+), 2 deletions(-) create mode 100644 tests/test_git_submodules.py create mode 100644 tests/test_gitsubproject.py create mode 100644 tests/test_metadata_nested.py create mode 100644 tests/test_stdout_reporter_nested.py create mode 100644 tests/test_util.py diff --git a/tests/test_git_submodules.py b/tests/test_git_submodules.py new file mode 100644 index 00000000..01af72d1 --- /dev/null +++ b/tests/test_git_submodules.py @@ -0,0 +1,313 @@ +"""Test git submodule functionality.""" + +# mypy: ignore-errors +# flake8: noqa + +from unittest.mock import Mock, patch + +import pytest + +from dfetch.vcs.git import GitLocalRepo, GitRemote, Submodule + + +class TestSubmodule: + """Tests for Submodule NamedTuple.""" + + def test_submodule_creation(self): + """Test creating a Submodule.""" + submodule = Submodule( + name="test-submodule", + toplevel="/path/to/toplevel", + path="ext/submodule", + sha="abc123def456", + url="https://example.com/submodule.git", + branch="main", + tag="v1.0", + ) + + assert submodule.name == "test-submodule" + assert submodule.toplevel == "/path/to/toplevel" + assert submodule.path == "ext/submodule" + assert submodule.sha == "abc123def456" + assert submodule.url == "https://example.com/submodule.git" + assert submodule.branch == "main" + assert submodule.tag == "v1.0" + + def test_submodule_as_tuple(self): + """Test that Submodule can be used as a tuple.""" + submodule = Submodule("name", "top", "path", "sha", "url", "branch", "tag") + + # Test unpacking + name, toplevel, path, sha, url, branch, tag = submodule + + assert name == "name" + assert toplevel == "top" + assert path == "path" + assert sha == "sha" + assert url == "url" + assert branch == "branch" + assert tag == "tag" + + +class TestGitRemoteBranchOrTagFromSha: + """Tests for GitRemote.find_branch_tip_or_tag_from_sha.""" + + @pytest.mark.parametrize( + "name, sha, expected_branch, expected_tag", + [ + ("branch-tip", "abc123", "main", ""), + ("tag", "def456", "", "v1.0"), + ("neither", "xyz789", "", ""), + ("short-sha", "abc", "main", ""), # "abc" matches "abc123" first (startswith) + ], + ) + def test_find_branch_tip_or_tag_from_sha( + self, name, sha, expected_branch, expected_tag + ): + """Test finding branch or tag from SHA.""" + # Mock ls_remote to return test data + mock_info = { + "refs/heads/main": "abc123", + "refs/heads/feature": "abc12345", # Starts with "abc" + "refs/tags/v1.0": "def456", + "refs/tags/v2.0": "ghi789", + } + + remote = GitRemote("https://example.com/repo.git") + + with patch.object(remote, "_ls_remote", return_value=mock_info): + branch, tag = remote.find_branch_tip_or_tag_from_sha(sha) + + assert branch == expected_branch + assert tag == expected_tag + + +class TestGitLocalRepoSubmodules: + """Tests for GitLocalRepo.submodules().""" + + def test_submodules_parsing(self): + """Test parsing submodule output.""" + # Mock the git submodule foreach output + mock_output = """submodule1 ext/sub1 abc123 /path/to/toplevel +submodule2 ext/sub2 def456 /path/to/toplevel +""" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + # Mock submodule URLs + mock_urls = { + "submodule1": "https://example.com/sub1.git", + "submodule2": "https://example.com/sub2.git", + } + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch.object( + GitLocalRepo, "_get_submodule_urls", return_value=mock_urls + ): + with patch.object( + GitRemote, "find_branch_tip_or_tag_from_sha", return_value=("main", "") + ): + submodules = GitLocalRepo.submodules() + + assert len(submodules) == 2 + assert submodules[0].name == "submodule1" + assert submodules[0].path == "ext/sub1" + assert submodules[0].sha == "abc123" + assert submodules[0].url == "https://example.com/sub1.git" + assert submodules[0].branch == "main" + + assert submodules[1].name == "submodule2" + assert submodules[1].path == "ext/sub2" + + def test_submodules_empty(self): + """Test when there are no submodules.""" + mock_result = Mock() + mock_result.stdout.decode.return_value = "" + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + submodules = GitLocalRepo.submodules() + + assert len(submodules) == 0 + + def test_submodules_with_tag(self): + """Test submodules that point to tags.""" + mock_output = "submodule1 ext/sub1 abc123 /path/to/toplevel\n" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + mock_urls = {"submodule1": "https://example.com/sub1.git"} + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch.object( + GitLocalRepo, "_get_submodule_urls", return_value=mock_urls + ): + with patch.object( + GitRemote, "find_branch_tip_or_tag_from_sha", return_value=("", "v1.0") + ): + submodules = GitLocalRepo.submodules() + + assert len(submodules) == 1 + assert submodules[0].tag == "v1.0" + assert submodules[0].branch == "" + + def test_submodules_fallback_to_local_branch(self): + """Test finding branch from local repo when remote doesn't have it.""" + mock_output = "submodule1 ext/sub1 abc123 /path/to/toplevel\n" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + mock_urls = {"submodule1": "https://example.com/sub1.git"} + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch.object( + GitLocalRepo, "_get_submodule_urls", return_value=mock_urls + ): + with patch.object( + GitRemote, "find_branch_tip_or_tag_from_sha", return_value=("", "") + ): + with patch.object( + GitLocalRepo, + "find_branch_containing_sha", + return_value="local-branch", + ): + submodules = GitLocalRepo.submodules() + + assert len(submodules) == 1 + assert submodules[0].branch == "local-branch" + assert submodules[0].tag == "" + + +class TestGitLocalRepoGetSubmoduleUrls: + """Tests for GitLocalRepo._get_submodule_urls().""" + + def test_get_submodule_urls(self): + """Test parsing submodule URLs from git config.""" + mock_output = """submodule.sub1.url https://example.com/sub1.git +submodule.sub2.url https://example.com/sub2.git +""" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch.object(GitLocalRepo, "get_remote_url", return_value=""): + urls = GitLocalRepo._get_submodule_urls("/path/to/toplevel") + + assert urls == { + "sub1": "https://example.com/sub1.git", + "sub2": "https://example.com/sub2.git", + } + + def test_get_submodule_urls_relative(self): + """Test resolving relative submodule URLs.""" + mock_output = """submodule.sub1.url ../relative/sub1.git +submodule.sub2.url ../../another/sub2.git +""" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch.object( + GitLocalRepo, + "get_remote_url", + return_value="https://example.com/org/repo.git", + ): + urls = GitLocalRepo._get_submodule_urls("/path/to/toplevel") + + assert urls["sub1"] == "https://example.com/org/relative/sub1.git" + assert urls["sub2"] == "https://example.com/another/sub2.git" + + +class TestGitLocalRepoEnsureAbsUrl: + """Tests for GitLocalRepo._ensure_abs_url().""" + + @pytest.mark.parametrize( + "name, root_url, rel_url, expected", + [ + ( + "absolute", + "https://example.com/org/repo.git", + "https://other.com/sub.git", + "https://other.com/sub.git", + ), + ( + "relative-one-level", + "https://example.com/org/repo.git", + "../sibling.git", + "https://example.com/org/sibling.git", + ), + ( + "relative-two-levels", + "https://example.com/org/team/repo.git", + "../../other/sub.git", + "https://example.com/org/other/sub.git", + ), + ( + "relative-complex", + "https://example.com/a/b/c/d.git", + "../../../x/y.git", + "https://example.com/a/x/y.git", + ), + ], + ) + def test_ensure_abs_url(self, name, root_url, rel_url, expected): + """Test ensuring absolute URLs.""" + result = GitLocalRepo._ensure_abs_url(root_url, rel_url) + assert result == expected + + +class TestGitLocalRepoFindBranchContainingSha: + """Tests for GitLocalRepo.find_branch_containing_sha().""" + + def test_find_branch_containing_sha(self): + """Test finding branch that contains a SHA.""" + mock_output = " feature-branch\n* main\n another-branch\n" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch("dfetch.vcs.git.in_directory"): + with patch("os.path.isdir", return_value=True): + repo = GitLocalRepo("/path/to/repo") + branch = repo.find_branch_containing_sha("abc123") + + # Should return first branch (after splitting by *) + assert branch == "feature-branch" + + def test_find_branch_no_branches(self): + """Test when no branches contain the SHA.""" + mock_output = "" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch("dfetch.vcs.git.in_directory"): + with patch("os.path.isdir", return_value=True): + repo = GitLocalRepo("/path/to/repo") + branch = repo.find_branch_containing_sha("abc123") + + assert branch == "" + + def test_find_branch_detached_head(self): + """Test handling detached HEAD state.""" + # When in detached HEAD, split by "*" yields empty string & detached HEAD msg + # The implementation filters out "HEAD detached" messages, leaving nothing + mock_output = "* (HEAD detached at abc123)\n main\n" + mock_result = Mock() + mock_result.stdout.decode.return_value = mock_output + + with patch("dfetch.vcs.git.run_on_cmdline", return_value=mock_result): + with patch("dfetch.vcs.git.in_directory"): + with patch("os.path.isdir", return_value=True): + repo = GitLocalRepo("/path/to/repo") + branch = repo.find_branch_containing_sha("abc123") + + # Returns empty string when only detached HEAD is present + assert branch == "" + + def test_find_branch_no_git_dir(self): + """Test when .git directory doesn't exist.""" + with patch("os.path.isdir", return_value=False): + repo = GitLocalRepo("/path/to/repo") + branch = repo.find_branch_containing_sha("abc123") + + assert branch == "" \ No newline at end of file diff --git a/tests/test_gitsubproject.py b/tests/test_gitsubproject.py new file mode 100644 index 00000000..1a1b39ad --- /dev/null +++ b/tests/test_gitsubproject.py @@ -0,0 +1,378 @@ +"""Test GitSubProject with submodule support.""" + +# mypy: ignore-errors +# flake8: noqa + +import datetime +from unittest.mock import Mock, patch + +import pytest + +from dfetch.manifest.project import ProjectEntry +from dfetch.manifest.version import Version +from dfetch.project.gitsubproject import GitSubProject +from dfetch.project.metadata import Options +from dfetch.vcs.git import Submodule + + +class TestGitSubProjectFetchWithNested: + """Tests for GitSubProject._fetch_impl with nested submodules.""" + + def test_fetch_impl_returns_nested_submodules(self): + """Test that _fetch_impl returns nested submodules.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + # Mock submodules returned by checkout_version + mock_submodules = [ + Submodule( + name="submodule1", + toplevel="/tmp/test-dest", + path="ext/sub1", + sha="abc123", + url="https://example.com/sub1.git", + branch="main", + tag="", + ), + Submodule( + name="submodule2", + toplevel="/tmp/test-dest", + path="ext/sub2", + sha="def456", + url="https://example.com/sub2.git", + branch="", + tag="v1.0", + ), + ] + + mock_local_repo = Mock() + mock_local_repo.checkout_version.return_value = ("fetched_sha_123", mock_submodules) + mock_local_repo.METADATA_DIR = ".git" + mock_local_repo.GIT_MODULES_FILE = ".gitmodules" + + version_to_fetch = Version(branch="main") + + with patch("dfetch.project.gitsubproject.GitLocalRepo", return_value=mock_local_repo): + with patch("dfetch.project.gitsubproject.pathlib.Path.mkdir"): + with patch("dfetch.project.gitsubproject.safe_rmtree"): + with patch("dfetch.project.gitsubproject.safe_rm"): + with patch.object( + subproject, "_determine_what_to_fetch", return_value="main" + ): + with patch.object( + subproject, + "_determine_fetched_version", + return_value=Version(branch="main", revision="fetched_sha_123"), + ): + fetched_version, nested = subproject._fetch_impl(version_to_fetch) + + # Verify nested submodules were returned + assert len(nested) == 2 + + # Check first submodule + assert nested[0]["remote_url"] == "https://example.com/sub1.git" + assert nested[0]["destination"] == "ext/sub1" + assert nested[0]["branch"] == "main" + assert nested[0]["tag"] == "" + assert nested[0]["revision"] == "abc123" + assert isinstance(nested[0]["last_fetch"], datetime.datetime) + + # Check second submodule + assert nested[1]["remote_url"] == "https://example.com/sub2.git" + assert nested[1]["destination"] == "ext/sub2" + assert nested[1]["branch"] == "" + assert nested[1]["tag"] == "v1.0" + assert nested[1]["revision"] == "def456" + + def test_fetch_impl_no_submodules(self): + """Test _fetch_impl when there are no submodules.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + mock_local_repo = Mock() + mock_local_repo.checkout_version.return_value = ("fetched_sha_123", []) + mock_local_repo.METADATA_DIR = ".git" + mock_local_repo.GIT_MODULES_FILE = ".gitmodules" + + version_to_fetch = Version(branch="main") + + with patch("dfetch.project.gitsubproject.GitLocalRepo", return_value=mock_local_repo): + with patch("dfetch.project.gitsubproject.pathlib.Path.mkdir"): + with patch("dfetch.project.gitsubproject.safe_rmtree"): + with patch("dfetch.project.gitsubproject.safe_rm"): + with patch.object( + subproject, "_determine_what_to_fetch", return_value="main" + ): + with patch.object( + subproject, + "_determine_fetched_version", + return_value=Version(branch="main", revision="fetched_sha_123"), + ): + fetched_version, nested = subproject._fetch_impl(version_to_fetch) + + # Verify no nested submodules + assert len(nested) == 0 + assert nested == [] + + def test_fetch_impl_nested_options_structure(self): + """Test that nested Options have correct structure.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + mock_submodules = [ + Submodule( + name="submodule1", + toplevel="/tmp/test-dest", + path="ext/sub1", + sha="abc123", + url="https://example.com/sub1.git", + branch="feature", + tag="", + ), + ] + + mock_local_repo = Mock() + mock_local_repo.checkout_version.return_value = ("fetched_sha_123", mock_submodules) + mock_local_repo.METADATA_DIR = ".git" + mock_local_repo.GIT_MODULES_FILE = ".gitmodules" + + version_to_fetch = Version(branch="main") + + with patch("dfetch.project.gitsubproject.GitLocalRepo", return_value=mock_local_repo): + with patch("dfetch.project.gitsubproject.pathlib.Path.mkdir"): + with patch("dfetch.project.gitsubproject.safe_rmtree"): + with patch("dfetch.project.gitsubproject.safe_rm"): + with patch.object( + subproject, "_determine_what_to_fetch", return_value="main" + ): + with patch.object( + subproject, + "_determine_fetched_version", + return_value=Version(branch="main", revision="fetched_sha_123"), + ): + fetched_version, nested = subproject._fetch_impl(version_to_fetch) + + # Verify all required fields are present in Options + required_fields = [ + "remote_url", + "destination", + "branch", + "tag", + "revision", + "last_fetch", + "nested", + "hash", + "patch", + ] + + for field in required_fields: + assert field in nested[0], f"Field '{field}' missing from nested Options" + + # Verify nested is always empty list (no recursive nesting) + assert nested[0]["nested"] == [] + assert nested[0]["hash"] == "" + assert nested[0]["patch"] == [] + + +class TestGitSubProjectDetermineFetchedVersion: + """Tests for GitSubProject._determine_fetched_version.""" + + def test_determine_fetched_version_with_tag(self): + """Test determining fetched version when tag is provided.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + with patch.object(subproject, "get_default_branch", return_value="master"): + version = Version(tag="v1.0.0") + result = subproject._determine_fetched_version(version, "abc123") + + assert result.tag == "v1.0.0" + assert result.branch == "master" + assert result.revision == "" + + def test_determine_fetched_version_with_revision(self): + """Test determining fetched version when revision is provided.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + with patch.object(subproject, "get_default_branch", return_value="master"): + version = Version(revision="abc123") + result = subproject._determine_fetched_version(version, "abc123") + + assert result.tag == "" + assert result.branch == "master" + assert result.revision == "abc123" + + def test_determine_fetched_version_with_branch_and_fetched_sha(self): + """Test determining fetched version with branch, using fetched SHA.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + version = Version(branch="develop") + result = subproject._determine_fetched_version(version, "fetched_sha_789") + + assert result.tag == "" + assert result.branch == "develop" + assert result.revision == "fetched_sha_789" + + +class TestGitSubProjectDetermineWhatToFetch: + """Tests for GitSubProject._determine_what_to_fetch.""" + + def test_determine_what_to_fetch_with_revision(self): + """Test determining what to fetch when revision is provided.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + version = Version(revision="abc123def456789012345678901234567890abcd") + result = subproject._determine_what_to_fetch(version) + + assert result == "abc123def456789012345678901234567890abcd" + + def test_determine_what_to_fetch_with_tag(self): + """Test determining what to fetch when tag is provided.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + version = Version(tag="v1.0.0") + result = subproject._determine_what_to_fetch(version) + + assert result == "v1.0.0" + + def test_determine_what_to_fetch_with_branch(self): + """Test determining what to fetch when branch is provided.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + version = Version(branch="feature") + result = subproject._determine_what_to_fetch(version) + + assert result == "feature" + + def test_determine_what_to_fetch_default_branch(self): + """Test determining what to fetch with default branch.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + with patch.object(subproject._remote_repo, "get_default_branch", return_value="main"): + version = Version() + result = subproject._determine_what_to_fetch(version) + + assert result == "main" + + def test_determine_what_to_fetch_short_revision_raises(self): + """Test that shortened revision raises an error.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + version = Version(revision="abc123") # Short revision + + with pytest.raises(RuntimeError, match="Shortened revisions"): + subproject._determine_what_to_fetch(version) + + def test_determine_what_to_fetch_revision_priority(self): + """Test that revision has priority over tag and branch.""" + project_entry = ProjectEntry( + { + "name": "test-project", + "url": "https://example.com/repo.git", + "dst": "/tmp/test-dest", + } + ) + + subproject = GitSubProject(project_entry) + + version = Version( + revision="abc123def456789012345678901234567890abcd", + tag="v1.0.0", + branch="feature", + ) + result = subproject._determine_what_to_fetch(version) + + # Revision should have priority + assert result == "abc123def456789012345678901234567890abcd" + + +class TestGitSubProjectRevisionIsEnough: + """Tests for GitSubProject.revision_is_enough.""" + + def test_revision_is_enough_returns_true(self): + """Test that git revision is enough to uniquely identify.""" + assert GitSubProject.revision_is_enough() is True \ No newline at end of file diff --git a/tests/test_metadata_nested.py b/tests/test_metadata_nested.py new file mode 100644 index 00000000..e2e6fd38 --- /dev/null +++ b/tests/test_metadata_nested.py @@ -0,0 +1,421 @@ +"""Test metadata with nested projects support.""" + +# mypy: ignore-errors +# flake8: noqa + +import datetime +import os +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from dfetch.manifest.project import ProjectEntry +from dfetch.manifest.version import Version +from dfetch.project.metadata import DONT_EDIT_WARNING, Metadata, Options + + +class TestMetadataOptions: + """Tests for Options TypedDict.""" + + def test_options_creation_complete(self): + """Test creating Options with all fields.""" + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "v1.0.0", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": ["patch1.txt", "patch2.txt"], + "nested": [], + } + + assert options["branch"] == "main" + assert options["tag"] == "v1.0.0" + assert options["nested"] == [] + assert isinstance(options["patch"], list) + + def test_options_creation_minimal(self): + """Test creating Options with minimal fields.""" + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "", + "tag": "", + "revision": "", + "remote_url": "", + "destination": "", + "hash": "", + "patch": "", + "nested": [], + } + + assert options["nested"] == [] + + +class TestMetadataNested: + """Tests for nested projects in Metadata.""" + + def test_metadata_creation_with_nested(self): + """Test creating metadata with nested projects.""" + nested_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "feature", + "tag": "", + "revision": "def456", + "remote_url": "https://example.com/nested.git", + "destination": "ext/nested", + "hash": "nestedhash", + "patch": [], + "nested": [], + } + + main_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 12, 30, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": ["patch.txt"], + "nested": [nested_options], + } + + metadata = Metadata(main_options) + + assert metadata.nested == [nested_options] + assert len(metadata.nested) == 1 + assert metadata.nested[0]["remote_url"] == "https://example.com/nested.git" + assert metadata.nested[0]["destination"] == "ext/nested" + + def test_metadata_creation_without_nested(self): + """Test creating metadata without nested projects.""" + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [], + } + + metadata = Metadata(options) + + assert metadata.nested == [] + + def test_metadata_fetched_with_nested(self): + """Test updating metadata with nested projects.""" + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [], + } + + metadata = Metadata(options) + + nested_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 2, 0, 0, 0), + "branch": "dev", + "tag": "v2.0", + "revision": "xyz789", + "remote_url": "https://example.com/nested2.git", + "destination": "ext/sub", + "hash": "subhash", + "patch": [], + "nested": [], + } + + new_version = Version(branch="main", revision="newrev123") + metadata.fetched( + version=new_version, + hash_="newhash", + patch_=["newpatch.txt"], + nested=[nested_options], + ) + + assert metadata.nested == [nested_options] + assert len(metadata.nested) == 1 + assert metadata.revision == "newrev123" + assert metadata.patch == ["newpatch.txt"] + + def test_metadata_equality_with_nested(self): + """Test metadata equality comparison with nested projects.""" + nested1: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "branch1", + "tag": "", + "revision": "rev1", + "remote_url": "url1", + "destination": "dest1", + "hash": "hash1", + "patch": [], + "nested": [], + } + + options1: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [nested1], + } + + options2: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [nested1], + } + + metadata1 = Metadata(options1) + metadata2 = Metadata(options2) + + assert metadata1 == metadata2 + + def test_metadata_inequality_with_different_nested(self): + """Test metadata inequality when nested projects differ.""" + nested1: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "branch1", + "tag": "", + "revision": "rev1", + "remote_url": "url1", + "destination": "dest1", + "hash": "hash1", + "patch": [], + "nested": [], + } + + nested2: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "branch2", + "tag": "", + "revision": "rev2", + "remote_url": "url2", + "destination": "dest2", + "hash": "hash2", + "patch": [], + "nested": [], + } + + options1: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [nested1], + } + + options2: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [nested2], + } + + metadata1 = Metadata(options1) + metadata2 = Metadata(options2) + + assert metadata1 != metadata2 + + +class TestMetadataDump: + """Tests for dumping metadata with nested projects.""" + + def test_dump_metadata_with_nested(self): + """Test dumping metadata file with nested projects.""" + with tempfile.TemporaryDirectory() as tmpdir: + nested_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "feature", + "tag": "v1.0", + "revision": "def456", + "remote_url": "https://example.com/nested.git", + "destination": "ext/nested", + "hash": "nestedhash", + "patch": ["nested.patch"], + "nested": [], + } + + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 12, 30, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": tmpdir, + "hash": "hash123", + "patch": ["main.patch"], + "nested": [nested_options], + } + + metadata = Metadata(options) + metadata.dump() + + # Verify file was created + metadata_file = os.path.join(tmpdir, Metadata.FILENAME) + assert os.path.exists(metadata_file) + + # Read and verify content + with open(metadata_file, "r", encoding="utf-8") as f: + content = f.read() + + # Check for warning + assert DONT_EDIT_WARNING in content + + # Check for nested section + assert "nested:" in content + + def test_dump_metadata_without_nested(self): + """Test dumping metadata file without nested projects.""" + with tempfile.TemporaryDirectory() as tmpdir: + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 12, 30, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": tmpdir, + "hash": "hash123", + "patch": [], + "nested": [], + } + + metadata = Metadata(options) + metadata.dump() + + # Verify file was created + metadata_file = os.path.join(tmpdir, Metadata.FILENAME) + assert os.path.exists(metadata_file) + + # Read and verify content + with open(metadata_file, "r", encoding="utf-8") as f: + content = f.read() + + # Check for warning + assert DONT_EDIT_WARNING in content + + # Nested section should not be present when empty + # (based on the code, it only adds nested if it exists) + lines = content.split('\n') + yaml_content = '\n'.join(line for line in lines if not line.strip().startswith('#')) + + # If nested is empty, it shouldn't be in the YAML + # (or it would be present but empty) + + def test_load_metadata_with_nested(self): + """Test loading metadata from file with nested projects.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create a metadata file with nested projects + metadata_content = """# This is a generated file by dfetch. Don't edit this, but edit the manifest. +# For more info see https://dfetch.rtfd.io/en/latest/getting_started.html +dfetch: + branch: main + hash: hash123 + last_fetch: 01/01/2026, 12:30:00 + nested: + - branch: feature + destination: ext/nested + hash: nestedhash + last_fetch: 01/01/2026, 00:00:00 + patch: [] + remote_url: https://example.com/nested.git + revision: def456 + tag: v1.0 + patch: main.patch + remote_url: https://example.com/repo.git + revision: abc123 + tag: '' +""" + metadata_file = os.path.join(tmpdir, Metadata.FILENAME) + with open(metadata_file, "w", encoding="utf-8") as f: + f.write(metadata_content) + + # Load the metadata + metadata = Metadata.from_file(metadata_file) + + # Verify the nested projects were loaded + assert len(metadata.nested) == 1 + assert metadata.nested[0]["remote_url"] == "https://example.com/nested.git" + assert metadata.nested[0]["destination"] == "ext/nested" + assert metadata.nested[0]["branch"] == "feature" + assert metadata.nested[0]["revision"] == "def456" + + +class TestMetadataMultipleNested: + """Tests for multiple nested projects.""" + + def test_metadata_with_multiple_nested(self): + """Test metadata with multiple nested projects.""" + nested1: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "branch1", + "tag": "", + "revision": "rev1", + "remote_url": "url1", + "destination": "ext/nested1", + "hash": "hash1", + "patch": [], + "nested": [], + } + + nested2: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "branch2", + "tag": "v2.0", + "revision": "rev2", + "remote_url": "url2", + "destination": "ext/nested2", + "hash": "hash2", + "patch": ["patch.txt"], + "nested": [], + } + + options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 12, 30, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": "/path/to/dest", + "hash": "hash123", + "patch": [], + "nested": [nested1, nested2], + } + + metadata = Metadata(options) + + assert len(metadata.nested) == 2 + assert metadata.nested[0]["destination"] == "ext/nested1" + assert metadata.nested[1]["destination"] == "ext/nested2" + assert metadata.nested[1]["tag"] == "v2.0" \ No newline at end of file diff --git a/tests/test_stdout_reporter_nested.py b/tests/test_stdout_reporter_nested.py new file mode 100644 index 00000000..bb129bbe --- /dev/null +++ b/tests/test_stdout_reporter_nested.py @@ -0,0 +1,123 @@ +"""Test StdoutReporter with nested projects support.""" + +# mypy: ignore-errors +# flake8: noqa + +import datetime +import tempfile +from unittest.mock import Mock + +from dfetch.manifest.project import ProjectEntry +from dfetch.project.metadata import Metadata, Options +from dfetch.reporting.stdout_reporter import StdoutReporter + + +class TestStdoutReporterName: + """Tests for StdoutReporter.name.""" + + def test_reporter_name(self): + """Test that reporter has correct name.""" + mock_manifest = Mock() + reporter = StdoutReporter(mock_manifest) + assert reporter.name == "stdout" + + +class TestStdoutReporterDumpToFile: + """Tests for StdoutReporter.dump_to_file.""" + + def test_dump_to_file_returns_false(self): + """Test that dump_to_file returns False (no-op for stdout reporter).""" + mock_manifest = Mock() + reporter = StdoutReporter(mock_manifest) + result = reporter.dump_to_file("dummy.txt") + + assert result is False + + +class TestMetadataWithNestedIntegration: + """Integration tests for metadata with nested projects.""" + + def test_metadata_dumps_nested_correctly(self): + """Test that metadata with nested projects is dumped correctly.""" + with tempfile.TemporaryDirectory() as tmpdir: + # Create nested options + nested_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "feature", + "tag": "v1.0", + "revision": "def456", + "remote_url": "https://example.com/nested.git", + "destination": "ext/nested", + "hash": "nestedhash", + "patch": [], + "nested": [], + } + + # Create main metadata with nested + main_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 12, 30, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": tmpdir, + "hash": "hash123", + "patch": ["main.patch"], + "nested": [nested_options], + } + + metadata = Metadata(main_options) + metadata.dump() + + # Verify the nested data is present + loaded_metadata = Metadata.from_file(metadata.path) + assert len(loaded_metadata.nested) == 1 + assert loaded_metadata.nested[0]["remote_url"] == "https://example.com/nested.git" + + def test_metadata_with_multiple_nested_projects(self): + """Test metadata with multiple nested projects.""" + with tempfile.TemporaryDirectory() as tmpdir: + nested1: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "branch1", + "tag": "", + "revision": "rev1", + "remote_url": "https://example.com/nested1.git", + "destination": "ext/nested1", + "hash": "hash1", + "patch": [], + "nested": [], + } + + nested2: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 0, 0, 0), + "branch": "", + "tag": "v2.0", + "revision": "rev2", + "remote_url": "https://example.com/nested2.git", + "destination": "ext/nested2", + "hash": "hash2", + "patch": ["nested.patch"], + "nested": [], + } + + main_options: Options = { + "last_fetch": datetime.datetime(2026, 1, 1, 12, 30, 0), + "branch": "main", + "tag": "", + "revision": "abc123", + "remote_url": "https://example.com/repo.git", + "destination": tmpdir, + "hash": "hash123", + "patch": [], + "nested": [nested1, nested2], + } + + metadata = Metadata(main_options) + metadata.dump() + + # Verify both nested projects are present + loaded_metadata = Metadata.from_file(metadata.path) + assert len(loaded_metadata.nested) == 2 + assert loaded_metadata.nested[0]["destination"] == "ext/nested1" + assert loaded_metadata.nested[1]["destination"] == "ext/nested2" \ No newline at end of file diff --git a/tests/test_subproject.py b/tests/test_subproject.py index b3503c29..51864340 100644 --- a/tests/test_subproject.py +++ b/tests/test_subproject.py @@ -10,14 +10,16 @@ from dfetch.manifest.project import ProjectEntry from dfetch.manifest.version import Version +from dfetch.project.metadata import Options from dfetch.project.subproject import SubProject class ConcreteSubProject(SubProject): _wanted_version: Version - def _fetch_impl(self, version: Version) -> Version: - return Version() + def _fetch_impl(self, version: Version) -> tuple[Version, list[Options]]: + # Returns a tuple of (Version, list of nested Options) + return Version(), [] def _latest_revision_on_branch(self, branch): return "latest" @@ -158,3 +160,20 @@ def test_ci_enabled( monkeypatch.setenv("CI", str(ci_env_value)) assert ConcreteSubProject._running_in_ci() == expected_result + + +def test_fetch_impl_returns_tuple_with_nested(): + """Test that _fetch_impl returns tuple of (Version, list[Options]).""" + subproject = ConcreteSubProject(ProjectEntry({"name": "proj1"})) + + result = subproject._fetch_impl(Version()) + + # Verify it returns a tuple + assert isinstance(result, tuple) + assert len(result) == 2 + + # Verify first element is Version + assert isinstance(result[0], Version) + + # Verify second element is list + assert isinstance(result[1], list) \ No newline at end of file diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 00000000..0151af18 --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,146 @@ +"""Test utility functions.""" + +# mypy: ignore-errors +# flake8: noqa + +import pytest + +from dfetch.util.util import always_str_list, str_if_possible + + +class TestAlwaysStrList: + """Tests for always_str_list function.""" + + @pytest.mark.parametrize( + "name, input_data, expected", + [ + ("single-string", "patch.txt", ["patch.txt"]), + ("list-of-strings", ["patch1.txt", "patch2.txt"], ["patch1.txt", "patch2.txt"]), + ("empty-string", "", []), + ("empty-list", [], []), + ("single-item-list", ["patch.txt"], ["patch.txt"]), + ("multiple-item-list", ["a", "b", "c"], ["a", "b", "c"]), + ("whitespace-string", " spaces ", [" spaces "]), + ("special-chars-string", "patch-file_v1.0.txt", ["patch-file_v1.0.txt"]), + ], + ) + def test_always_str_list(self, name, input_data, expected): + """Test always_str_list converts data correctly.""" + result = always_str_list(input_data) + assert result == expected + assert isinstance(result, list) + + def test_always_str_list_returns_same_list_object(self): + """Test that passing a list returns the same list object.""" + input_list = ["a", "b"] + result = always_str_list(input_list) + assert result is input_list + + +class TestStrIfPossible: + """Tests for str_if_possible function.""" + + @pytest.mark.parametrize( + "name, input_data, expected", + [ + ("single-item", ["patch.txt"], "patch.txt"), + ("multiple-items", ["patch1.txt", "patch2.txt"], ["patch1.txt", "patch2.txt"]), + ("empty-list", [], ""), + ("three-items", ["a", "b", "c"], ["a", "b", "c"]), + ("single-empty-string", [""], ""), + ("whitespace-single", [" "], " "), + ("special-chars", ["patch-v1.0_final.txt"], "patch-v1.0_final.txt"), + ], + ) + def test_str_if_possible(self, name, input_data, expected): + """Test str_if_possible converts data correctly.""" + result = str_if_possible(input_data) + assert result == expected + + def test_str_if_possible_preserves_list_object(self): + """Test that multi-item lists are returned unchanged.""" + input_list = ["a", "b", "c"] + result = str_if_possible(input_list) + assert result is input_list + + +class TestRoundTripConversion: + """Test round-trip conversions between always_str_list and str_if_possible.""" + + @pytest.mark.parametrize( + "name, original", + [ + ("single-string", "patch.txt"), + ("empty-string", ""), + ("list-single", ["patch.txt"]), + ("list-multiple", ["a", "b"]), + ("list-empty", []), + ], + ) + def test_roundtrip_always_to_str(self, name, original): + """Test that always_str_list -> str_if_possible works correctly.""" + intermediate = always_str_list(original) + result = str_if_possible(intermediate) + + # Check the result matches expected behavior + if isinstance(original, str): + if original: + assert result == original + else: + assert result == "" + elif len(original) == 0: + assert result == "" + elif len(original) == 1: + assert result == original[0] + else: + assert result == original + + @pytest.mark.parametrize( + "name, original", + [ + ("empty-list", []), + ("single-item", ["patch.txt"]), + ("multiple-items", ["a", "b", "c"]), + ], + ) + def test_roundtrip_str_to_always(self, name, original): + """Test that str_if_possible -> always_str_list works correctly.""" + intermediate = str_if_possible(original) + result = always_str_list(intermediate) + + # Result should always be a list + assert isinstance(result, list) + if len(original) == 0: + assert result == [] + elif len(original) == 1: + assert result == original + else: + assert result == original + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_always_str_list_with_unicode(self): + """Test handling of Unicode strings.""" + unicode_str = "patch_ñ_文字.txt" + result = always_str_list(unicode_str) + assert result == [unicode_str] + + def test_str_if_possible_with_unicode(self): + """Test handling of Unicode in lists.""" + unicode_list = ["patch_ñ.txt"] + result = str_if_possible(unicode_list) + assert result == "patch_ñ.txt" + + def test_always_str_list_with_paths(self): + """Test handling of path-like strings.""" + path = "path/to/patch.txt" + result = always_str_list(path) + assert result == [path] + + def test_str_if_possible_with_paths(self): + """Test handling of path-like strings in lists.""" + paths = ["path/to/patch1.txt"] + result = str_if_possible(paths) + assert result == "path/to/patch1.txt" \ No newline at end of file