diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-25 17:49:35 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-26 00:20:05 +0900 |
| commit | d4ec1dcb6fc04ff029ded35cc2fcc14f3bbc02ad (patch) | |
| tree | 0a85bd085961fcdfd5570cafc3b44ba5eb8cb152 /crates/shirabe/tests/repository | |
| parent | 22d0b327368e5f39de6f381046c081d08efdba15 (diff) | |
| download | php-shirabe-d4ec1dcb6fc04ff029ded35cc2fcc14f3bbc02ad.tar.gz php-shirabe-d4ec1dcb6fc04ff029ded35cc2fcc14f3bbc02ad.tar.zst php-shirabe-d4ec1dcb6fc04ff029ded35cc2fcc14f3bbc02ad.zip | |
test: port 32 command/repository/downloader tests
Add create_installed_json/create_composer_lock test helpers. Port command (8),
repository path/forgejo/perforce/vcs (11), and fossil/hg/download_manager (13)
tests. Fix production porting bugs: root_package_loader/forgejo_url/version_bumper
regex delimiters, repository_manager create_repository_by_class, array_loader
isset, licenses_command RefCell borrow; implement disk_free_space and
touch2/touch3 via libc.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe/tests/repository')
4 files changed, 439 insertions, 86 deletions
diff --git a/crates/shirabe/tests/repository/path_repository_test.rs b/crates/shirabe/tests/repository/path_repository_test.rs index 5679c38..bbf4938 100644 --- a/crates/shirabe/tests/repository/path_repository_test.rs +++ b/crates/shirabe/tests/repository/path_repository_test.rs @@ -7,6 +7,8 @@ use indexmap::IndexMap; use shirabe::config::Config; use shirabe::io::{IOInterface, NullIO}; use shirabe::repository::PathRepository; +use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; use shirabe::util::{Platform, ProcessExecutor}; use shirabe_php_shim::{ DIRECTORY_SEPARATOR, PhpMixed, file_get_contents, hash, realpath, serialize, @@ -28,6 +30,16 @@ fn create_path_repo(options: IndexMap<String, PhpMixed>) -> PathRepository { let config = Rc::new(RefCell::new(Config::new(true, None))); let proc = Rc::new(RefCell::new(ProcessExecutor::new(None))); + // ref: createPathRepo wires the ProcessExecutor through a Loop so the VersionGuesser's async + // git calls are permitted; constructing the Loop calls enable_async() on the shared executor. + let http_downloader = Rc::new(RefCell::new(HttpDownloader::new( + io.clone(), + config.clone(), + IndexMap::new(), + false, + ))); + let _loop = Loop::new(http_downloader, Some(proc.clone())); + PathRepository::new(options, io, config, None, None, Some(proc)).unwrap() } @@ -68,7 +80,6 @@ fn test_load_package_from_file_system_with_version() { ); } -#[ignore = "version guessing for an unversioned package calls VersionGuesser::guess_git_version, which reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_load_package_from_file_system_without_version() { let repository_url = [ @@ -90,7 +101,6 @@ fn test_load_package_from_file_system_without_version() { assert!(!package_version.is_empty()); } -#[ignore = "the without-version fixture matched by the wildcard triggers VersionGuesser::guess_git_version, which reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_load_package_from_file_system_with_wildcard() { let repository_url = @@ -197,7 +207,6 @@ fn test_url_remains_relative() { assert_eq!(Some(relative_url), package.get_dist_url()); } -#[ignore = "the wildcard url also matches the without-version fixture, whose version guessing reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_reference_none() { let options = coordinates(vec![("reference", PhpMixed::String("none".to_string()))]); @@ -216,7 +225,6 @@ fn test_reference_none() { } } -#[ignore = "the wildcard url also matches the without-version fixture, whose version guessing reaches stream_set_blocking (fcntl(2) not implemented, todo!()) and aborts the process"] #[test] fn test_reference_config() { let options = coordinates(vec![ diff --git a/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs b/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs index ea130ea..1f9ae53 100644 --- a/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/forgejo_driver_test.rs @@ -9,15 +9,18 @@ use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; use shirabe::repository::vcs::ForgejoDriver; use shirabe::util::filesystem::Filesystem; +use shirabe::util::http_downloader::{HttpDownloader, HttpDownloaderMockHandler}; +use shirabe::util::process_executor::{MockHandler, ProcessExecutor}; use shirabe_php_shim::PhpMixed; use tempfile::TempDir; +use crate::http_downloader_mock::{HttpDownloaderMockGuard, expect_full, get_http_downloader_mock}; +use crate::process_executor_mock::{ProcessExecutorMockGuard, get_process_executor_mock}; + struct SetUp { home: TempDir, - config: Config, - // The IOInterface and HttpDownloader mocks are not ported. - io: (), - http_downloader: (), + config: Rc<RefCell<Config>>, + io: Rc<RefCell<dyn IOInterface>>, } fn set_up() -> SetUp { @@ -36,14 +39,14 @@ fn set_up() -> SetUp { top.insert("config".to_string(), PhpMixed::Array(config_section)); config.merge(&top, Config::SOURCE_UNKNOWN); - let io = (); - let http_downloader = (); + // PHP mocks IOInterface with isInteractive() => true; on the passing (200) paths + // exercised here the interactivity flag is never consulted, so a bare NullIO matches. + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); SetUp { home, - config, + config: Rc::new(RefCell::new(config)), io, - http_downloader, } } @@ -68,82 +71,233 @@ impl Drop for TearDown { } } -fn supports_provider() -> Vec<(bool, &'static str)> { - vec![ - (false, "https://example.org/acme/repo"), - (true, "https://codeberg.org/acme/repository"), - ] +// ref: ForgejoDriverTest::initializeDriver +fn initialize_driver( + set_up: &SetUp, + repo_url: &str, + http_downloader: Rc<RefCell<HttpDownloader>>, +) -> (ForgejoDriver, ProcessExecutorMockGuard) { + let (process, process_guard) = get_process_executor_mock(vec![], false, MockHandler::default()); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert("url".to_string(), PhpMixed::String(repo_url.to_string())); + let mut driver = ForgejoDriver::new( + repo_config, + set_up.io.clone(), + set_up.config.clone(), + http_downloader, + process, + ); + driver.initialize().unwrap(); + + (driver, process_guard) } -#[test] -#[ignore] -fn test_supports() { - for (expected, repo_url) in supports_provider() { - let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); - let config = Rc::new(RefCell::new(Config::new(true, None))); +fn http_mock( + expectations: Vec<shirabe::util::http_downloader::HttpDownloaderMockExpectation>, +) -> (Rc<RefCell<HttpDownloader>>, HttpDownloaderMockGuard) { + get_http_downloader_mock(expectations, true, HttpDownloaderMockHandler::default()) +} - assert_eq!( - expected, - ForgejoDriver::supports(io, config, repo_url, false).unwrap() +fn repo_body() -> String { + shirabe::json::JsonFile::encode(&PhpMixed::Array({ + let mut m: IndexMap<String, PhpMixed> = IndexMap::new(); + m.insert( + "default_branch".to_string(), + PhpMixed::String("main".to_string()), ); - } + m.insert("has_issues".to_string(), PhpMixed::Bool(true)); + m.insert("archived".to_string(), PhpMixed::Bool(false)); + m.insert("private".to_string(), PhpMixed::Bool(false)); + m.insert( + "html_url".to_string(), + PhpMixed::String("https://codeberg.org/acme/repo".to_string()), + ); + m.insert( + "ssh_url".to_string(), + PhpMixed::String("git@codeberg.org:acme/repo.git".to_string()), + ); + m.insert( + "clone_url".to_string(), + PhpMixed::String("https://codeberg.org/acme/repo.git".to_string()), + ); + m + })) } -// The remaining cases construct a ForgejoDriver and mock the HttpDownloader to return -// Forgejo API responses; mocking is not available, and a real HttpDownloader reaches -// curl_multi_init (todo!()). #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_public_repository() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let (http_downloader, _http_guard) = http_mock(vec![expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + )]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + assert_eq!("main", driver.get_root_identifier().unwrap()); + + let sha = "SOMESHA"; + let dist = driver.get_dist(sha).unwrap(); + assert_eq!("zip", dist.get("type").unwrap()); + assert_eq!( + "https://codeberg.org/api/v1/repos/acme/repo/archive/SOMESHA.zip", + dist.get("url").unwrap() + ); + assert_eq!(sha, dist.get("reference").unwrap()); + + let source = driver.get_source(sha); + assert_eq!("git", source.get("type").unwrap()); + assert_eq!( + "https://codeberg.org/acme/repo.git", + source.get("url").unwrap() + ); + assert_eq!(sha, source.get("reference").unwrap()); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_get_branches() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let branches_body = shirabe::json::JsonFile::encode(&PhpMixed::List(vec![PhpMixed::Array({ + let mut commit: IndexMap<String, PhpMixed> = IndexMap::new(); + commit.insert("id".to_string(), PhpMixed::String("SOMESHA".to_string())); + let mut entry: IndexMap<String, PhpMixed> = IndexMap::new(); + entry.insert("name".to_string(), PhpMixed::String("main".to_string())); + entry.insert("commit".to_string(), PhpMixed::Array(commit)); + entry + })])); + + let (http_downloader, _http_guard) = http_mock(vec![ + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + ), + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo/branches?per_page=100", + None, + 200, + branches_body, + vec![String::new()], + ), + ]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert("main".to_string(), "SOMESHA".to_string()); + assert_eq!(expected, driver.get_branches().unwrap()); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_get_tags() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let tags_body = shirabe::json::JsonFile::encode(&PhpMixed::List(vec![PhpMixed::Array({ + let mut commit: IndexMap<String, PhpMixed> = IndexMap::new(); + commit.insert("sha".to_string(), PhpMixed::String("SOMESHA".to_string())); + let mut entry: IndexMap<String, PhpMixed> = IndexMap::new(); + entry.insert("name".to_string(), PhpMixed::String("1.0".to_string())); + entry.insert("commit".to_string(), PhpMixed::Array(commit)); + entry + })])); + + let (http_downloader, _http_guard) = http_mock(vec![ + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + ), + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo/tags?per_page=100", + None, + 200, + tags_body, + vec![String::new()], + ), + ]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + + let mut expected: IndexMap<String, String> = IndexMap::new(); + expected.insert("1.0".to_string(), "SOMESHA".to_string()); + assert_eq!(expected, driver.get_tags().unwrap()); } #[test] -#[ignore = "HttpDownloaderMock (getHttpDownloaderMock) and the IOInterface MockObject are not ported"] fn test_get_empty_file_content() { - let SetUp { - home, - config, - io, + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + let (http_downloader, _http_guard) = http_mock(vec![ + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo", + None, + 200, + repo_body(), + vec![String::new()], + ), + expect_full( + "https://codeberg.org/api/v1/repos/acme/repo/contents/composer.json?ref=main", + None, + 200, + "{\"encoding\":\"base64\",\"content\":\"\"}", + vec![String::new()], + ), + ]); + + let (mut driver, _process_guard) = initialize_driver( + &set_up, + "https://codeberg.org/acme/repo.git", http_downloader, - } = set_up(); - let _tear_down = TearDown::new(home.path().to_path_buf()); - let _ = (&config, &io, &http_downloader); - todo!() + ); + + assert_eq!( + Some(String::new()), + driver.get_file_content("composer.json", "main").unwrap() + ); +} + +fn supports_provider() -> Vec<(bool, &'static str)> { + vec![ + (false, "https://example.org/acme/repo"), + (true, "https://codeberg.org/acme/repository"), + ] +} + +#[test] +fn test_supports() { + let set_up = set_up(); + let _tear_down = TearDown::new(set_up.home.path().to_path_buf()); + + for (expected, repo_url) in supports_provider() { + assert_eq!( + expected, + ForgejoDriver::supports(set_up.io.clone(), set_up.config.clone(), repo_url, false) + .unwrap() + ); + } } diff --git a/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs b/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs index d9178dd..5e9471f 100644 --- a/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs +++ b/crates/shirabe/tests/repository/vcs/perforce_driver_test.rs @@ -108,7 +108,6 @@ fn test_supports_returns_false_no_deep_check() { // The remaining cases mock Perforce, the repository config and IO to drive initialization, // composer-file detection and cleanup; mocking is not available here. -#[ignore] #[test] fn test_initialize_captures_variables_from_repo_config() { let SetUp { @@ -132,9 +131,13 @@ fn test_initialize_captures_variables_from_repo_config() { IndexMap::new(), false, ))); - let process = Rc::new(RefCell::new(shirabe::util::ProcessExecutor::new(Some( - io.clone(), - )))); + // ref: setUp uses getProcessExecutorMock(); the empty, non-strict mock lets Perforce's p4 + // queries resolve with the default (exit 0) result so initialize() never shells out to p4. + let (process, _process_guard) = crate::process_executor_mock::get_process_executor_mock( + vec![], + false, + shirabe::util::process_executor::MockHandler::default(), + ); let mut driver = PerforceDriver::new(repo_config, io, config, http_downloader, process); driver.initialize().unwrap(); diff --git a/crates/shirabe/tests/repository/vcs_repository_test.rs b/crates/shirabe/tests/repository/vcs_repository_test.rs index d84ed51..279db04 100644 --- a/crates/shirabe/tests/repository/vcs_repository_test.rs +++ b/crates/shirabe/tests/repository/vcs_repository_test.rs @@ -1,29 +1,145 @@ //! ref: composer/tests/Composer/Test/Repository/VcsRepositoryTest.php use std::cell::RefCell; +use std::collections::HashSet; +use std::process::Command; use std::rc::Rc; use indexmap::IndexMap; +use serial_test::serial; use shirabe::config::Config; use shirabe::io::IOInterface; use shirabe::io::null_io::NullIO; -use shirabe::package::dumper::ArrayDumper; -use shirabe::repository::RepositoryInterface; use shirabe::repository::VcsRepository; +use shirabe::util::ProcessExecutor; use shirabe::util::filesystem::Filesystem; use shirabe::util::http_downloader::HttpDownloader; +use shirabe::util::r#loop::Loop; use shirabe_php_shim::PhpMixed; +use tempfile::TempDir; struct SetUp { - composer_home: std::path::PathBuf, - git_repo: std::path::PathBuf, + composer_home: TempDir, + git_repo: TempDir, } -fn set_up() -> SetUp { - // setUp lazily runs initialize(), which shells out to git to build a fixture repository - // on disk; the ExecutableFinder/ProcessExecutor-driven setup and the markTestSkipped - // skip path are not ported. - todo!() +// ref: VcsRepositoryTest::initialize. Builds a fixture git repository on disk by shelling out to +// git. Returns None when git is unavailable (mirroring markTestSkipped). +fn set_up() -> Option<SetUp> { + if which_git().is_none() { + // 'This test needs a git binary in the PATH to be able to run' + return None; + } + + let composer_home = TempDir::new().unwrap(); + let git_repo = TempDir::new().unwrap(); + let path = git_repo.path(); + + let exec = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(path) + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("GIT_CONFIG_SYSTEM", "/dev/null") + .status() + .unwrap_or_else(|e| panic!("Failed to execute git {:?}: {}", args, e)); + if !status.success() { + panic!("Failed to execute git {:?}", args); + } + }; + let write_file = |name: &str, contents: &str| { + std::fs::write(path.join(name), contents).unwrap(); + }; + + // init + exec(&["init", "-q"]); + exec(&["checkout", "-b", "master"]); + exec(&["config", "user.email", "composertest@example.org"]); + exec(&["config", "user.name", "ComposerTest"]); + exec(&["config", "commit.gpgsign", "false"]); + write_file("foo", ""); + exec(&["add", "foo"]); + exec(&["commit", "-m", "init"]); + + // non-composed tag & branch + exec(&["tag", "0.5.0"]); + exec(&["branch", "oldbranch"]); + + // add composed tag & master branch + write_file( + "composer.json", + &shirabe::json::JsonFile::encode(&composer(None)), + ); + exec(&["add", "composer.json"]); + exec(&["commit", "-m", "addcomposer"]); + exec(&["tag", "0.6.0"]); + + // add feature-a branch + exec(&["checkout", "-b", "feature/a-1.0-B"]); + write_file("foo", "bar feature"); + exec(&["add", "foo"]); + exec(&["commit", "-m", "change-a"]); + + // add foo#bar branch which should result in dev-foo+bar + exec(&["branch", "foo#bar"]); + + // add version to composer.json + exec(&["checkout", "master"]); + write_file( + "composer.json", + &shirabe::json::JsonFile::encode(&composer(Some("1.0.0"))), + ); + exec(&["add", "composer.json"]); + exec(&["commit", "-m", "addversion"]); + + // create tag with wrong version in it + exec(&["tag", "0.9.0"]); + // create tag with correct version in it + exec(&["tag", "1.0.0"]); + + // add feature-b branch + exec(&["checkout", "-b", "feature-b"]); + write_file("foo", "baz feature"); + exec(&["add", "foo"]); + exec(&["commit", "-m", "change-b"]); + + // add 1.0 branch + exec(&["checkout", "master"]); + exec(&["branch", "1.0"]); + + // add 1.0.x branch + exec(&["branch", "1.1.x"]); + + // update master to 2.0 + write_file( + "composer.json", + &shirabe::json::JsonFile::encode(&composer(Some("2.0.0"))), + ); + exec(&["add", "composer.json"]); + exec(&["commit", "-m", "bump-version"]); + + Some(SetUp { + composer_home, + git_repo, + }) +} + +fn composer(version: Option<&str>) -> PhpMixed { + let mut m: IndexMap<String, PhpMixed> = IndexMap::new(); + m.insert("name".to_string(), PhpMixed::String("a/b".to_string())); + if let Some(version) = version { + m.insert("version".to_string(), PhpMixed::String(version.to_string())); + } + PhpMixed::Array(m) +} + +fn which_git() -> Option<()> { + Command::new("git") + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|_| ()) } fn tear_down(composer_home: &std::path::Path, git_repo: &std::path::Path) { @@ -52,13 +168,85 @@ impl Drop for TearDown { } } -// testLoadVersions initialises a real git repository on disk and drives a VcsRepository over -// it, then asserts the loaded package versions; the git fixture setup and constraint parsing -// (look-around regex) are not ported. In addition, VcsRepository does not implement -// RepositoryInterface and keeps its inner ArrayRepository pub(crate), so getPackages() (inherited -// from ArrayRepository in PHP) is not reachable from the test crate. #[test] -#[ignore = "VcsRepository does not expose get_packages() to the test crate; git fixture setup not ported"] +#[serial] fn test_load_versions() { - todo!() + let Some(set_up) = set_up() else { + // git binary unavailable; skip like markTestSkipped. + return; + }; + let composer_home = set_up.composer_home.path().to_path_buf(); + let git_repo = set_up.git_repo.path().to_path_buf(); + + let mut expected: HashSet<String> = [ + "0.6.0", + "1.0.0", + "1.0.x-dev", + "1.1.x-dev", + "dev-feature-b", + "dev-feature/a-1.0-B", + "dev-foo+bar", + "dev-master", + "9999999-dev", // alias of dev-master + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + let mut config = Config::new(true, None); + let mut top: IndexMap<String, PhpMixed> = IndexMap::new(); + let mut config_section: IndexMap<String, PhpMixed> = IndexMap::new(); + config_section.insert( + "home".to_string(), + PhpMixed::String(composer_home.to_string_lossy().into_owned()), + ); + top.insert("config".to_string(), PhpMixed::Array(config_section)); + config.merge(&top, Config::SOURCE_UNKNOWN); + let config = Rc::new(RefCell::new(config)); + + let io: Rc<RefCell<dyn IOInterface>> = Rc::new(RefCell::new(NullIO::new())); + let http_downloader = Rc::new(RefCell::new(HttpDownloader::new( + io.clone(), + config.clone(), + IndexMap::new(), + false, + ))); + let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone())))); + // VcsRepository's git driver / VersionGuesser run async git processes; constructing a Loop + // enables async on the shared ProcessExecutor. + let _loop = Loop::new(http_downloader.clone(), Some(process.clone())); + + let mut repo_config: IndexMap<String, PhpMixed> = IndexMap::new(); + repo_config.insert( + "url".to_string(), + PhpMixed::String(git_repo.to_string_lossy().into_owned()), + ); + repo_config.insert("type".to_string(), PhpMixed::String("vcs".to_string())); + + let mut repo = VcsRepository::new( + repo_config, + io, + config, + http_downloader, + None, + Some(process), + None, + None, + ) + .unwrap(); + + let _tear_down = TearDown::new(composer_home, git_repo); + + let packages = repo.__get_packages().unwrap(); + + for package in &packages { + let pretty = package.get_pretty_version(); + assert!(expected.remove(&pretty), "Unexpected version {}", pretty); + } + + assert!( + expected.is_empty(), + "Missing versions: {}", + expected.into_iter().collect::<Vec<_>>().join(", ") + ); } |
