1
- """Property-based tests for lock file models .
1
+ """Property-based tests for configuration lock .
2
2
3
3
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 .
6
6
"""
7
7
8
8
from __future__ import annotations
9
9
10
- import datetime
11
- from pathlib import Path
12
- from typing import Any , Callable
10
+ import pathlib
11
+ import typing as t
13
12
14
13
import hypothesis .strategies as st
15
- from hypothesis import given
14
+ from hypothesis import given , settings
16
15
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
18
18
19
19
20
- # Define strategies for generating test data
21
20
@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 :
23
22
"""Generate valid URLs for repositories."""
24
23
protocols = ["https://" , "http://" , "git://" , "ssh://git@" ]
25
24
domains = ["github.com" , "gitlab.com" , "bitbucket.org" , "example.com" ]
@@ -50,7 +49,7 @@ def valid_url_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
50
49
51
50
52
51
@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 :
54
53
"""Generate valid paths for repositories."""
55
54
base_dirs = ["~/code" , "~/projects" , "/tmp" , "./projects" ]
56
55
sub_dirs = [
@@ -75,154 +74,130 @@ def valid_path_strategy(draw: Callable[[st.SearchStrategy[Any]], Any]) -> str:
75
74
76
75
77
76
@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."""
125
79
name = draw (st .one_of (st .none (), st .text (min_size = 1 , max_size = 20 )))
126
80
url = draw (valid_url_strategy ())
127
81
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
+ )
131
100
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 (
133
118
name = name ,
134
119
url = url ,
135
120
path = path ,
136
121
vcs = vcs ,
122
+ remotes = remotes ,
137
123
rev = rev ,
138
- locked_at = locked_at ,
124
+ web_url = web_url ,
139
125
)
140
126
141
127
142
128
@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 ,
155
159
repositories = repositories ,
160
+ includes = includes ,
156
161
)
157
162
158
163
159
- class TestLockedRepositoryProperties :
160
- """Property-based tests for the LockedRepository model ."""
164
+ class TestLockProperties :
165
+ """Property-based tests for the lock mechanism ."""
161
166
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
170
187
) -> 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