1- """Property-based tests for lock file models .
1+ """Property-based tests for configuration lock .
22
33This 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
88from __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
1413import 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