Skip to content

Commit ef96a3a

Browse files
committed
!squash tests(feat[property]): Add property-based testin
1 parent 3bfdb8d commit ef96a3a

File tree

1 file changed

+118
-143
lines changed

1 file changed

+118
-143
lines changed
Lines changed: 118 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
1-
"""Property-based tests for lock file models.
1+
"""Property-based tests for configuration lock.
22
33
This module contains property-based tests using Hypothesis for the
4-
VCSPull lock file models to ensure they meet invariants and
5-
handle edge cases properly.
4+
VCSPull configuration lock to ensure it properly handles versioning
5+
and change tracking.
66
"""
77

88
from __future__ import annotations
99

10-
import datetime
11-
from pathlib import Path
12-
from typing import Any, Callable
10+
import pathlib
11+
import typing as t
1312

1413
import hypothesis.strategies as st
15-
from hypothesis import given
14+
from hypothesis import given, settings
1615

17-
from vcspull.config.models import LockedRepository, LockFile
16+
from vcspull.config.lock import calculate_lock_from_config, load_lock, save_lock
17+
from vcspull.config.models import Repository, Settings, VCSPullConfig
1818

1919

20-
# Define strategies for generating test data
2120
@st.composite
22-
def valid_url_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
21+
def valid_url_strategy(draw: t.Callable[[st.SearchStrategy[t.Any]], t.Any]) -> str:
2322
"""Generate valid URLs for repositories."""
2423
protocols = ["https://", "http://", "git://", "ssh://git@"]
2524
domains = ["github.com", "gitlab.com", "bitbucket.org", "example.com"]
@@ -50,7 +49,7 @@ def valid_url_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
5049

5150

5251
@st.composite
53-
def valid_path_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
52+
def valid_path_strategy(draw: t.Callable[[st.SearchStrategy[t.Any]], t.Any]) -> str:
5453
"""Generate valid paths for repositories."""
5554
base_dirs = ["~/code", "~/projects", "/tmp", "./projects"]
5655
sub_dirs = [
@@ -75,154 +74,130 @@ def valid_path_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
7574

7675

7776
@st.composite
78-
def valid_revision_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
79-
"""Generate valid revision strings for repositories."""
80-
# Git commit hash (40 chars hex)
81-
git_hash = draw(st.text(alphabet="0123456789abcdef", min_size=7, max_size=40))
82-
83-
# Git branch/tag (simpler text)
84-
git_ref = draw(
85-
st.text(
86-
alphabet="abcdefghijklmnopqrstuvwxyz0123456789-_/.",
87-
min_size=1,
88-
max_size=20,
89-
),
90-
)
91-
92-
# SVN revision number
93-
svn_rev = str(draw(st.integers(min_value=1, max_value=10000)))
94-
95-
# HG changeset ID
96-
hg_id = draw(st.text(alphabet="0123456789abcdef", min_size=12, max_size=40))
97-
98-
result: str = draw(st.sampled_from([git_hash, git_ref, svn_rev, hg_id]))
99-
return result
100-
101-
102-
@st.composite
103-
def datetime_strategy(
104-
draw: Callable[[st.SearchStrategy[Any]], Any],
105-
) -> datetime.datetime:
106-
"""Generate valid datetime objects within a reasonable range."""
107-
# Using fixed datetimes to avoid flaky behavior
108-
datetimes = [
109-
datetime.datetime(2020, 1, 1),
110-
datetime.datetime(2021, 6, 15),
111-
datetime.datetime(2022, 12, 31),
112-
datetime.datetime(2023, 3, 10),
113-
datetime.datetime(2024, 1, 1),
114-
]
115-
116-
result: datetime.datetime = draw(st.sampled_from(datetimes))
117-
return result
118-
119-
120-
@st.composite
121-
def locked_repository_strategy(
122-
draw: Callable[[st.SearchStrategy[Any]], Any],
123-
) -> LockedRepository:
124-
"""Generate valid LockedRepository instances."""
77+
def repository_strategy(draw: t.Callable[[st.SearchStrategy[t.Any]], t.Any]) -> Repository:
78+
"""Generate valid Repository instances."""
12579
name = draw(st.one_of(st.none(), st.text(min_size=1, max_size=20)))
12680
url = draw(valid_url_strategy())
12781
path = draw(valid_path_strategy())
128-
vcs = draw(st.sampled_from(["git", "hg", "svn"]))
129-
rev = draw(valid_revision_strategy())
130-
locked_at = draw(datetime_strategy())
82+
vcs = draw(st.one_of(st.none(), st.sampled_from(["git", "hg", "svn"])))
83+
84+
# Optionally generate remotes
85+
remotes = {}
86+
if draw(st.booleans()):
87+
remote_names = ["upstream", "origin", "fork"]
88+
remote_count = draw(st.integers(min_value=1, max_value=3))
89+
for _ in range(remote_count):
90+
remote_name = draw(st.sampled_from(remote_names))
91+
if remote_name not in remotes: # Avoid duplicates
92+
remotes[remote_name] = draw(valid_url_strategy())
93+
94+
rev = draw(
95+
st.one_of(
96+
st.none(),
97+
st.text(min_size=1, max_size=40), # Can be branch name, tag, or commit hash
98+
),
99+
)
131100

132-
return LockedRepository(
101+
web_url = draw(
102+
st.one_of(
103+
st.none(),
104+
st.sampled_from(
105+
[
106+
f"https://github.com/user/{name}"
107+
if name
108+
else "https://github.com/user/repo",
109+
f"https://gitlab.com/user/{name}"
110+
if name
111+
else "https://gitlab.com/user/repo",
112+
],
113+
),
114+
),
115+
)
116+
117+
return Repository(
133118
name=name,
134119
url=url,
135120
path=path,
136121
vcs=vcs,
122+
remotes=remotes,
137123
rev=rev,
138-
locked_at=locked_at,
124+
web_url=web_url,
139125
)
140126

141127

142128
@st.composite
143-
def lock_file_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> LockFile:
144-
"""Generate valid LockFile instances."""
145-
version = draw(st.sampled_from(["1.0.0", "1.0.1", "1.1.0"]))
146-
created_at = draw(datetime_strategy())
147-
148-
# Generate between 0 and 5 locked repositories
149-
repo_count = draw(st.integers(min_value=0, max_value=5))
150-
repositories = [draw(locked_repository_strategy()) for _ in range(repo_count)]
151-
152-
return LockFile(
153-
version=version,
154-
created_at=created_at,
129+
def settings_strategy(draw: t.Callable[[st.SearchStrategy[t.Any]], t.Any]) -> Settings:
130+
"""Generate valid Settings instances."""
131+
sync_remotes = draw(st.booleans())
132+
default_vcs = draw(st.one_of(st.none(), st.sampled_from(["git", "hg", "svn"])))
133+
depth = draw(st.one_of(st.none(), st.integers(min_value=1, max_value=10)))
134+
135+
return Settings(
136+
sync_remotes=sync_remotes,
137+
default_vcs=default_vcs,
138+
depth=depth,
139+
)
140+
141+
142+
@st.composite
143+
def vcspull_config_strategy(
144+
draw: t.Callable[[st.SearchStrategy[t.Any]], t.Any]
145+
) -> VCSPullConfig:
146+
"""Generate valid VCSPullConfig instances."""
147+
settings = draw(settings_strategy())
148+
149+
# Generate between 1 and 5 repositories
150+
repo_count = draw(st.integers(min_value=1, max_value=5))
151+
repositories = [draw(repository_strategy()) for _ in range(repo_count)]
152+
153+
# Optionally generate includes
154+
include_count = draw(st.integers(min_value=0, max_value=3))
155+
includes = [f"include{i}.yaml" for i in range(include_count)]
156+
157+
return VCSPullConfig(
158+
settings=settings,
155159
repositories=repositories,
160+
includes=includes,
156161
)
157162

158163

159-
class TestLockedRepositoryProperties:
160-
"""Property-based tests for the LockedRepository model."""
164+
class TestLockProperties:
165+
"""Property-based tests for the lock mechanism."""
161166

162-
@given(
163-
url=valid_url_strategy(),
164-
path=valid_path_strategy(),
165-
vcs=st.sampled_from(["git", "hg", "svn"]),
166-
rev=valid_revision_strategy(),
167-
)
168-
def test_minimal_locked_repository_properties(
169-
self, url: str, path: str, vcs: str, rev: str
167+
@given(config=vcspull_config_strategy())
168+
def test_lock_calculation(self, config: VCSPullConfig, tmp_path: pathlib.Path) -> None:
169+
"""Test lock calculation from config."""
170+
# Calculate lock from config (without accessing real repositories)
171+
lock = calculate_lock_from_config(config, dry_run=True)
172+
173+
# Check basic lock properties
174+
assert "version" in lock
175+
assert "repositories" in lock
176+
assert isinstance(lock["repositories"], dict)
177+
178+
# Check that all repositories are included
179+
assert len(lock["repositories"]) == len(config.repositories)
180+
for repo in config.repositories:
181+
repo_name = repo.name or repo.get_name()
182+
assert repo_name in lock["repositories"]
183+
184+
@given(config=vcspull_config_strategy())
185+
def test_lock_save_load_roundtrip(
186+
self, config: VCSPullConfig, tmp_path: pathlib.Path
170187
) -> None:
171-
"""Test properties of locked repositories."""
172-
repo = LockedRepository(url=url, path=path, vcs=vcs, rev=rev)
173-
174-
# Check invariants
175-
assert repo.url == url
176-
assert Path(repo.path).is_absolute()
177-
assert repo.path.startswith("/") # Path should be absolute after normalization
178-
assert repo.vcs in {"git", "hg", "svn"}
179-
assert repo.rev == rev
180-
assert isinstance(repo.locked_at, datetime.datetime)
181-
182-
@given(repo=locked_repository_strategy())
183-
def test_locked_repository_roundtrip(self, repo: LockedRepository) -> None:
184-
"""Test locked repository serialization and deserialization."""
185-
# Roundtrip test: convert to dict and back to model
186-
repo_dict = repo.model_dump()
187-
repo2 = LockedRepository.model_validate(repo_dict)
188-
189-
# The resulting object should match the original
190-
assert repo2.url == repo.url
191-
assert repo2.path == repo.path
192-
assert repo2.name == repo.name
193-
assert repo2.vcs == repo.vcs
194-
assert repo2.rev == repo.rev
195-
assert repo2.locked_at == repo.locked_at
196-
197-
198-
class TestLockFileProperties:
199-
"""Property-based tests for the LockFile model."""
200-
201-
@given(lock_file=lock_file_strategy())
202-
def test_lock_file_roundtrip(self, lock_file: LockFile) -> None:
203-
"""Test lock file serialization and deserialization."""
204-
# Roundtrip test: convert to dict and back to model
205-
lock_dict = lock_file.model_dump()
206-
lock_file2 = LockFile.model_validate(lock_dict)
207-
208-
# The resulting object should match the original
209-
assert lock_file2.version == lock_file.version
210-
assert lock_file2.created_at == lock_file.created_at
211-
assert len(lock_file2.repositories) == len(lock_file.repositories)
212-
213-
@given(lock_file=lock_file_strategy())
214-
def test_lock_file_repository_paths(self, lock_file: LockFile) -> None:
215-
"""Test that locked repositories have valid paths."""
216-
for repo in lock_file.repositories:
217-
# All paths should be absolute after normalization
218-
assert Path(repo.path).is_absolute()
219-
220-
@given(lock_file=lock_file_strategy())
221-
def test_semver_version_format(self, lock_file: LockFile) -> None:
222-
"""Test that the version follows semver format."""
223-
# Version should be in the format x.y.z
224-
assert lock_file.version.count(".") == 2
225-
major, minor, patch = lock_file.version.split(".")
226-
assert major.isdigit()
227-
assert minor.isdigit()
228-
assert patch.isdigit()
188+
"""Test saving and loading a lock file."""
189+
# Calculate lock
190+
lock = calculate_lock_from_config(config, dry_run=True)
191+
192+
# Save lock to file
193+
lock_path = tmp_path / "vcspull.lock.json"
194+
save_lock(lock, lock_path)
195+
196+
# Load lock from file
197+
loaded_lock = load_lock(lock_path)
198+
199+
# Check that loaded lock matches original
200+
assert loaded_lock["version"] == lock["version"]
201+
assert set(loaded_lock["repositories"].keys()) == set(
202+
lock["repositories"].keys()
203+
)

0 commit comments

Comments
 (0)