Skip to content

Commit fb5799c

Browse files
Refactor installer rule resolution, add test cases, improve TrackedFs API
- Update rule resolution to support relative file exclusions and simplify rule mapping - Refactor TrackedFs trait to return Result for delete operations and clarify FileAction semantics - Enhance installer_tests! macro and Testficate harness for better accuracy reporting and asset dir resolution - Add new bepinex installer test cases for h3vr community packages - Fix directory copy and delete logic for tracked/untracked file operations - Minor cleanup in ecosystem modloader data retrieval
1 parent f5f263a commit fb5799c

File tree

6 files changed

+178
-60
lines changed

6 files changed

+178
-60
lines changed

src/game/ecosystem.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ pub async fn get_modloader_data(
4747
ecosystem_schema: &EcosystemSchema,
4848
) -> Result<Option<R2MMModLoaderPackage>, Error> {
4949
let package_ident = package.to_loose_ident_string();
50-
50+
5151
let modloader_package = ecosystem_schema
5252
.modloader_packages
5353
.iter()

src/package/install/bepinex.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ impl<T: TrackedFs> PackageInstaller for BpxInstaller<T> {
6868
}
6969

7070
let r2 = game_def.r2modman.as_ref().unwrap().first().unwrap();
71-
let rules = rule::resolve_install_rules(package_dir, &r2.install_rules)?;
71+
let rules = rule::resolve_install_rules(
72+
package_dir,
73+
&r2.install_rules,
74+
r2.relative_file_exclusions.as_deref(),
75+
)?;
7276

7377
for (rule, sources) in rules {
7478
match rule.tracking_method {
@@ -190,5 +194,21 @@ mod tests {
190194
installer_variant: "bepinex",
191195
init_index: false,
192196
},
197+
198+
test_bepinex_install_h3vr_r6_weapons_pack => {
199+
packages: ["sirpotatos-R6_Weapons_Pack-2.2.0"],
200+
community: "h3vr",
201+
asset_name: "R6_Weapons_Pack",
202+
installer_variant: "bepinex",
203+
init_index: false,
204+
},
205+
206+
test_bepinex_install_h3vr_modmas_2021 => {
207+
packages: ["Modmas2021-Modmas_2021-25.0.18"],
208+
community: "h3vr",
209+
asset_name: "Modmas_2021",
210+
installer_variant: "bepinex",
211+
init_index: false,
212+
},
193213
}
194214
}

src/package/install/rule/rule_install.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,7 @@ pub async fn install_untracked(
6464
continue;
6565
}
6666

67-
let dest_path = rule_dir.join(source_path.file_name().unwrap());
68-
file::copy_dir(source_path, &dest_path).await?;
67+
file::copy_dir(source_path, &rule_dir).await?;
6968
}
7069

7170
Ok(())

src/package/install/rule/rule_resolver.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pub fn resolve_install_rule<'a>(
5555
pub fn resolve_install_rules<'a>(
5656
root_path: &Path,
5757
install_rules: &'a [R2MMInstallRule],
58+
relative_file_exclusions: Option<&[String]>,
5859
) -> Result<HashMap<&'a R2MMInstallRule, Vec<PathBuf>>, Error> {
5960
if !root_path.exists() {
6061
Err(IoError::new(
@@ -63,11 +64,13 @@ pub fn resolve_install_rules<'a>(
6364
))?;
6465
}
6566

67+
let exclusions = relative_file_exclusions.unwrap_or_default();
6668
let entries: Vec<_> = WalkDir::new(root_path)
6769
.min_depth(1) // Skip the root.
6870
.max_depth(2) // Grab the files and dirs in the first level of the root dir.
6971
.into_iter()
7072
.filter_map(|x| x.ok())
73+
.filter(|x| !exclusions.contains(&x.file_name().to_string_lossy().to_string()))
7174
.collect();
7275
let mut rules: HashMap<&'a R2MMInstallRule, Vec<PathBuf>> = HashMap::new();
7376

@@ -76,27 +79,21 @@ pub fn resolve_install_rules<'a>(
7679
for file_entry in files {
7780
let file_path = file_entry.path();
7881
if let Some(rule) = resolve_install_rule(file_path, install_rules, None) {
79-
rules
80-
.entry(rule)
81-
.or_insert_with(Vec::new)
82-
.push(file_path.to_path_buf());
82+
rules.entry(rule).or_default().push(file_path.to_path_buf());
8383
}
8484
}
8585

8686
for dir_entry in dirs {
8787
let dir_path = dir_entry.path();
8888

8989
if let Some(rule) = resolve_install_rule(dir_path, install_rules, Some(root_path)) {
90-
rules
91-
.entry(rule)
92-
.or_insert_with(Vec::new)
93-
.push(dir_path.to_path_buf());
90+
rules.entry(rule).or_default().push(dir_path.to_path_buf());
9491
continue;
9592
}
9693

97-
let sub_rules = resolve_install_rules(dir_path, install_rules)?;
94+
let sub_rules = resolve_install_rules(dir_path, install_rules, relative_file_exclusions)?;
9895
for (rule, paths) in sub_rules {
99-
rules.entry(rule).or_insert_with(Vec::new).extend(paths);
96+
rules.entry(rule).or_default().extend(paths);
10097
}
10198
}
10299

@@ -170,7 +167,7 @@ mod tests {
170167
},
171168
];
172169

173-
let result = resolve_install_rules(&test_root, &install_rules).unwrap();
170+
let result = resolve_install_rules(&test_root, &install_rules, None).unwrap();
174171

175172
// Find the rule with route "one" and check its paths
176173
let rule_one = install_rules.iter().find(|r| r.route == "one").unwrap();
@@ -234,7 +231,7 @@ mod tests {
234231
},
235232
];
236233

237-
let result = resolve_install_rules(&test_root, &install_rules).unwrap();
234+
let result = resolve_install_rules(&test_root, &install_rules, None).unwrap();
238235

239236
// Find the rule with route "plugins" and check its paths
240237
let rule_plugins = install_rules.iter().find(|r| r.route == "plugins").unwrap();

src/package/install/testificate.rs

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
use std::collections::HashSet;
1+
use std::collections::{HashMap, HashSet};
22
use std::fmt;
3-
use std::path::{Path, PathBuf};
3+
use std::path::PathBuf;
4+
use std::env;
45

56
use tokio::sync::OnceCell;
67

@@ -12,6 +13,8 @@ use crate::ui::reporter::VoidReporter;
1213
use crate::util::file;
1314
use crate::TCLI_HOME;
1415

16+
/// Dirty macro to define an installer test.
17+
/// See package/install/bepinex.rs for usage example.
1518
#[macro_export]
1619
macro_rules! installer_tests {
1720
(
@@ -35,7 +38,7 @@ macro_rules! installer_tests {
3538
$installer_variant,
3639
$init_index,
3740
);
38-
let results = test.run_test().await.unwrap();
41+
let results = test.run_test().await.expect("Installer test failed");
3942

4043
if results.has_failures {
4144
panic!("{results}");
@@ -59,23 +62,94 @@ pub struct TestResult {
5962

6063
impl fmt::Display for TestResult {
6164
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62-
let total_files = self.only_proj.len() + self.only_asset.len() + self.in_both.len();
63-
let ratio = match total_files {
64-
0 => 100.0,
65-
n => (self.in_both.len() as f64 / n as f64) * 100.0,
66-
};
67-
68-
write!(f, "\n[{:.2}%] Mismatch for packages {}:\n Project dir: {}\n Asset dir: {}\n Only in project:\n\t{}\n Only in asset:\n\t{}\n",
69-
ratio,
70-
self.packages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", "),
65+
let matches = self.in_both.len();
66+
let mismatches = self.only_proj.len() + self.only_asset.len();
67+
let total = matches + mismatches;
68+
let accuracy = if total == 0 { 100.0 } else { (matches as f64 / total as f64) * 100.0 };
69+
70+
write!(
71+
f,
72+
"\n[Accuracy: {:.2}%] Installer comparison for packages {}:\n Project dir: {}\n Asset dir: {}\n Summary: matches={}, mismatches={}, total={}\n",
73+
accuracy,
74+
self.packages
75+
.iter()
76+
.map(|p| p.to_string())
77+
.collect::<Vec<_>>()
78+
.join(", "),
7179
self.project_dir.display(),
7280
self.asset_dir.display(),
73-
format_paths(&self.only_proj),
74-
format_paths(&self.only_asset),
75-
)
81+
matches,
82+
mismatches,
83+
total,
84+
)?;
85+
86+
self.fmt_only_proj(f)?;
87+
self.fmt_only_asset(f)?;
88+
Ok(())
89+
}
90+
}
91+
92+
impl TestResult {
93+
fn fmt_only_proj(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94+
self.fmt_only_section(f, " Only in project:", &self.only_proj, &self.only_asset)
95+
}
96+
97+
fn fmt_only_asset(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98+
self.fmt_only_section(f, " Only in asset:", &self.only_asset, &self.only_proj)
99+
}
100+
101+
fn fmt_only_section(
102+
&self,
103+
f: &mut fmt::Formatter<'_>,
104+
header: &str,
105+
primary: &HashSet<PathBuf>,
106+
secondary: &HashSet<PathBuf>,
107+
) -> fmt::Result {
108+
if primary.is_empty() {
109+
return Ok(());
110+
}
111+
112+
writeln!(f, "{}", header)?;
113+
114+
let secondary_by_name: HashMap<String, Vec<PathBuf>> = secondary
115+
.iter()
116+
.filter_map(|path| path.file_name().map(|name| (name.to_string_lossy().to_string(), path.clone())))
117+
.fold(HashMap::new(), |mut acc, (name, path)| {
118+
acc.entry(name).or_insert_with(Vec::new).push(path);
119+
acc
120+
});
121+
122+
let mut primary_paths: Vec<_> = primary
123+
.iter()
124+
.filter_map(|p| {
125+
p.file_name().map(|name| (name.to_string_lossy(), p))
126+
})
127+
.filter(|(name, _)| {
128+
!matches!(name.as_ref(), "README.md" | "icon.png" | "manifest.json")
129+
})
130+
.collect();
131+
primary_paths.sort_by(|a, b| a.1.cmp(b.1));
132+
133+
for (name, primary_path) in primary_paths {
134+
writeln!(f, " • {}", primary_path.display())?;
135+
let name_str = name.to_string();
136+
if let Some(matching) = secondary_by_name.get(&name_str) {
137+
writeln!(f, " ↳ Matching files:")?;
138+
let mut matches: Vec<_> = matching.iter().collect();
139+
matches.sort();
140+
for m in matches {
141+
writeln!(f, " • {}", m.display())?;
142+
}
143+
}
144+
}
145+
Ok(())
76146
}
77147
}
78148

149+
/// Testificate is a test harness providing an easy way to run regression tests on installers.
150+
///
151+
/// It sets up a temporary project, installs the specified packages, and compares the resulting
152+
/// file structure against a known-good asset directory.
79153
pub struct Testficate {
80154
packages: Vec<PackageReference>,
81155
community: String,
@@ -85,11 +159,20 @@ pub struct Testficate {
85159
}
86160

87161
impl Testficate {
88-
pub fn new(packages: Vec<PackageReference>, community: &str, assets_name: &str, installer_variant: &str, init_index: bool) -> Self {
89-
let asset_dir = Path::new(env!("CARGO_WORKSPACE_DIR"))
90-
.join("resources/test/installers")
91-
.join(installer_variant)
92-
.join(assets_name);
162+
pub fn new(
163+
packages: Vec<PackageReference>,
164+
community: &str,
165+
assets_name: &str,
166+
installer_variant: &str,
167+
init_index: bool,
168+
) -> Self {
169+
let asset_dir = match env::var("CARGO_WORKSPACE_DIR") {
170+
Ok(val) => PathBuf::from(val),
171+
Err(_) => PathBuf::from("."), // fallback to current dir
172+
}
173+
.join("resources/test/installers")
174+
.join(installer_variant)
175+
.join(assets_name);
93176

94177
Self {
95178
packages,
@@ -105,21 +188,22 @@ impl Testficate {
105188
return;
106189
}
107190

191+
// For tests we don't need full error handling. Run the check once and
192+
// sync if needed. Use unwrap_or(true) so that an error will trigger a
193+
// sync attempt rather than bubbling complex errors in the test harness.
108194
self.init
109195
.get_or_init(|| async {
110-
if PackageIndex::requires_update(&TCLI_HOME)
111-
.await
112-
.unwrap_or_default()
113-
{
114-
PackageIndex::sync(&TCLI_HOME).await.unwrap();
196+
if PackageIndex::requires_update(&TCLI_HOME).await.unwrap_or(true) {
197+
// Best-effort sync for tests; ignore errors rather than panicking.
198+
let _ = PackageIndex::sync(&TCLI_HOME).await;
115199
}
116200
})
117201
.await;
118202
}
119203

120204
pub async fn run_test(self) -> Result<TestResult, Error> {
121205
self.init_index().await;
122-
206+
123207
let project_dir = tempfile::tempdir()?;
124208
let project = Project::create_new(
125209
project_dir.path(),
@@ -144,11 +228,5 @@ impl Testficate {
144228
has_failures,
145229
})
146230
}
147-
148231
}
149232

150-
fn format_paths(paths: &HashSet<PathBuf>) -> String {
151-
let mut v: Vec<_> = paths.iter().map(|p| p.display().to_string()).collect();
152-
v.sort();
153-
v.join("\n\t")
154-
}

0 commit comments

Comments
 (0)