mod common;
use std::collections::BTreeMap;
use std::path::Path;
use common::{make_temp_root, read_compile_set, read_file, write_file};
use unity_solution_generator::{
BuildConfig, BuildPlatform, DllRef, GenerateOptions, GeneratorError, Lockfile, LockfileIO,
ProjectScanner, RefCategory, SolutionGenerator,
};
const GR: &str = "tpl";
fn opts(root: &Path, platform: BuildPlatform, cfg: BuildConfig) -> GenerateOptions {
GenerateOptions::new(root.to_string_lossy().into_owned(), platform)
.with_generator_root(GR)
.with_build_config(cfg)
}
fn small_lockfile() -> Lockfile {
let mut refs: BTreeMap<RefCategory, Vec<DllRef>> = BTreeMap::new();
refs.insert(
RefCategory::Engine,
vec![DllRef::new(
"UnityEngine",
"$(UnityPath)/Unity.app/Contents/Managed/UnityEngine/UnityEngine.dll",
)],
);
refs.insert(RefCategory::Editor, vec![]);
refs.insert(RefCategory::Netstandard, vec![]);
refs.insert(RefCategory::PlaybackIos, vec![]);
refs.insert(RefCategory::PlaybackAndroid, vec![]);
refs.insert(RefCategory::PlaybackStandalone, vec![]);
refs.insert(RefCategory::Project, vec![]);
Lockfile {
unity_version: "6000.2.7f2".into(),
unity_path: "/test/unity".into(),
lang_version: "9.0".into(),
analyzers: vec![],
refs,
defines: vec!["UNITY_6000".into()],
defines_scripting: vec![],
}
}
#[test]
fn regeneration_is_byte_identical() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"Lib"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let g = SolutionGenerator::new();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let csproj1 = read_file(root, "tpl/ios-editor/Lib.csproj");
let sln1 = read_file(root, &format!("tpl/ios-editor/{}.sln", root.file_name().unwrap().to_string_lossy()));
let props1 = read_file(root, "tpl/ios-editor/Directory.Build.props");
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let csproj2 = read_file(root, "tpl/ios-editor/Lib.csproj");
let sln2 = read_file(root, &format!("tpl/ios-editor/{}.sln", root.file_name().unwrap().to_string_lossy()));
let props2 = read_file(root, "tpl/ios-editor/Directory.Build.props");
assert_eq!(csproj1, csproj2);
assert_eq!(sln1, sln2);
assert_eq!(props1, props2);
}
#[test]
fn multi_variant_from_same_lockfile_share_scan_cache() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"Lib"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let g = SolutionGenerator::new();
for (p, c) in [
(BuildPlatform::Ios, BuildConfig::Editor),
(BuildPlatform::Ios, BuildConfig::Prod),
(BuildPlatform::Android, BuildConfig::Editor),
(BuildPlatform::Osx, BuildConfig::Editor),
] {
let r = g
.generate_from_lockfile(&opts(root, p, c), &lf)
.unwrap();
assert!(r.variant_csprojs.iter().any(|s| s.ends_with("Lib.csproj")));
}
for v in ["ios-editor", "ios-prod", "android-editor", "osx-editor"] {
assert!(
root.join("tpl").join(v).join("Lib.csproj").exists(),
"variant {} missing",
v
);
}
}
#[test]
fn native_plugin_dirs_skipped() {
let tmp = make_temp_root();
let root = tmp.path();
write_file(root, "Assets/Plugin/x86_64/Code.cs", "class Native {}\n");
write_file(root, "Assets/Plugin/Code.cs", "class Plain {}\n");
let scan = ProjectScanner::scan(root.to_str().unwrap(), GR).unwrap();
let total_dirs: usize = scan.dirs_by_project.values().map(|v| v.len()).sum::<usize>()
+ scan.unresolved_dirs.len();
assert!(
total_dirs >= 2,
"expected at least 2 cs dirs (x86_64 + parent), got {}",
total_dirs
);
}
#[test]
fn asmdef_version_defines_filtered_by_manifest() {
let tmp = make_temp_root();
let root = tmp.path();
write_file(
root,
"Packages/manifest.json",
r#"{"dependencies":{"com.unity.modules.physics2d":"1.0.0"}}"#,
);
write_file(
root,
"Assets/A/Lib.asmdef",
r#"{"name":"Lib","versionDefines":[
{"name":"com.unity.modules.physics2d","expression":"","define":"HAS_PHYSICS"},
{"name":"com.unity.modules.audio","expression":"","define":"HAS_AUDIO"},
{"name":"Unity","expression":"","define":"HAS_UNITY"}
]}"#,
);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let scan = ProjectScanner::scan(root.to_str().unwrap(), GR).unwrap();
let asm = scan.asm_def_by_name.get("Lib").unwrap();
assert_eq!(asm.version_defines.len(), 3);
let names: std::collections::HashSet<_> =
asm.version_defines.iter().map(|v| v.define.as_str()).collect();
assert!(names.contains("HAS_PHYSICS"));
assert!(names.contains("HAS_AUDIO"));
assert!(names.contains("HAS_UNITY"));
}
#[test]
fn duplicate_asmdef_name_errors() {
let tmp = make_temp_root();
let root = tmp.path();
write_file(root, "Assets/A/Foo.asmdef", r#"{"name":"Foo"}"#);
write_file(root, "Assets/B/Foo.asmdef", r#"{"name":"Foo"}"#);
write_file(root, "Assets/A/X.cs", "class X {}\n");
write_file(root, "Assets/B/Y.cs", "class Y {}\n");
let err = ProjectScanner::scan(root.to_str().unwrap(), GR).unwrap_err();
match err {
GeneratorError::DuplicateAsmDefName(n) => assert_eq!(n, "Foo"),
other => panic!("expected DuplicateAsmDefName, got {:?}", other),
}
}
#[test]
fn lockfile_round_trip_preserves_unicode() {
let tmp = make_temp_root();
let root = tmp.path();
let path = root.join("csproj.lock");
let mut lf = small_lockfile();
lf.defines.push("FÜRBALL_测试".to_string());
LockfileIO::write(&lf, path.to_str().unwrap()).unwrap();
let r = LockfileIO::read(path.to_str().unwrap()).unwrap();
assert_eq!(r.defines, lf.defines);
}
#[test]
fn extra_refs_ordering_preserves_lockfile_first() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Main.asmdef", r#"{"name":"Main"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let extra = vec![
DllRef::new("UnityEngine", "/SHOULD_NOT_WIN/UnityEngine.dll"),
DllRef::new("MyExtra", "/path/MyExtra.dll"),
];
SolutionGenerator::new()
.generate_from_lockfile(
&opts(root, BuildPlatform::Ios, BuildConfig::Editor).with_extra_refs(extra),
&lf,
)
.unwrap();
let csproj = read_file(root, "tpl/ios-editor/Main.csproj");
assert!(csproj.contains("Managed/UnityEngine/UnityEngine.dll"));
assert!(!csproj.contains("/SHOULD_NOT_WIN/UnityEngine.dll"));
assert!(csproj.contains("MyExtra"));
}
#[test]
fn changing_asmref_target_reroutes_sources() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Asm1.asmdef", r#"{"name":"Asm1"}"#);
write_file(root, "Assets/B/Asm2.asmdef", r#"{"name":"Asm2"}"#);
write_file(root, "Assets/A/A.cs", "class A {}\n");
write_file(root, "Assets/B/B.cs", "class B {}\n");
write_file(root, "Assets/Game/Assembly.asmref", r#"{"reference":"Asm1"}"#);
write_file(root, "Assets/Game/Code.cs", "class Code {}\n");
let g = SolutionGenerator::new();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let asm1 = read_compile_set(root, "tpl/ios-editor/Asm1.csproj");
let asm2 = read_compile_set(root, "tpl/ios-editor/Asm2.csproj");
assert!(asm1.contains("Assets/Game/Code.cs"));
assert!(!asm2.contains("Assets/Game/Code.cs"));
std::thread::sleep(std::time::Duration::from_millis(10));
write_file(root, "Assets/Game/Assembly.asmref", r#"{"reference":"Asm2"}"#);
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let asm1 = read_compile_set(root, "tpl/ios-editor/Asm1.csproj");
let asm2 = read_compile_set(root, "tpl/ios-editor/Asm2.csproj");
assert!(!asm1.contains("Assets/Game/Code.cs"));
assert!(asm2.contains("Assets/Game/Code.cs"));
}
#[test]
fn writefile_if_changed_skips_unchanged() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"Lib"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let g = SolutionGenerator::new();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let csproj_path = root.join("tpl/ios-editor/Lib.csproj");
let mtime_before = std::fs::metadata(&csproj_path).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let mtime_after = std::fs::metadata(&csproj_path).unwrap().modified().unwrap();
assert_eq!(
mtime_before, mtime_after,
"csproj mtime changed despite no-op regenerate"
);
}
#[test]
fn output_dir_with_trailing_slash_normalised() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"Lib"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let r = SolutionGenerator::new()
.generate_from_lockfile(
&opts(root, BuildPlatform::Ios, BuildConfig::Editor)
.with_output_dir(Some("Library/foo/bar/")),
&lf,
)
.unwrap();
assert_eq!(r.variant_csprojs, vec!["Library/foo/bar/Lib.csproj"]);
}
#[test]
fn empty_extra_refs_string_parses_empty() {
let parsed = DllRef::parse_list("");
assert!(parsed.is_empty());
}
#[test]
fn inplace_asmdef_edit_invalidates_scan_cache() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"OldName"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let g = SolutionGenerator::new();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
assert!(root.join("tpl/ios-editor/OldName.csproj").exists());
std::thread::sleep(std::time::Duration::from_millis(20));
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"NewName"}"#);
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
assert!(
root.join("tpl/ios-editor/NewName.csproj").exists(),
"rename should be picked up via per-file mtime tracking"
);
}
#[test]
fn no_lockfile_no_templates_falls_back_via_cli_path() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/Foo.cs", "class Foo {}\n");
let r = SolutionGenerator::new()
.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
assert!(
r.variant_csprojs
.iter()
.any(|s| s.ends_with("Assembly-CSharp.csproj")),
"expected Assembly-CSharp fallback, got {:?}",
r.variant_csprojs
);
}
#[test]
fn generate_fingerprint_short_circuits_render() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"Lib"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let lockfile_path = root.join("Library/UnitySolutionGenerator/csproj.lock");
std::fs::create_dir_all(lockfile_path.parent().unwrap()).unwrap();
LockfileIO::write(&lf, lockfile_path.to_str().unwrap()).unwrap();
let g = SolutionGenerator::new();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let csproj_path = root.join("tpl/ios-editor/Lib.csproj");
let mtime_first = std::fs::metadata(&csproj_path).unwrap().modified().unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
let r2 = g
.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
let mtime_second = std::fs::metadata(&csproj_path).unwrap().modified().unwrap();
assert_eq!(
mtime_first, mtime_second,
"fingerprint hit must not rewrite csproj"
);
assert!(r2.variant_csprojs.iter().any(|s| s.ends_with("Lib.csproj")));
std::thread::sleep(std::time::Duration::from_millis(20));
let mut lf2 = lf.clone();
lf2.defines.push("MY_NEW_DEFINE".to_string());
std::fs::remove_file(&lockfile_path).unwrap();
LockfileIO::write(&lf2, lockfile_path.to_str().unwrap()).unwrap();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf2)
.unwrap();
let props = read_file(root, "tpl/ios-editor/Directory.Build.props");
assert!(
props.contains("MY_NEW_DEFINE"),
"lockfile change must invalidate fingerprint and re-render props"
);
}
#[test]
fn generate_fingerprint_invalidates_when_csproj_deleted() {
let tmp = make_temp_root();
let root = tmp.path();
let lf = small_lockfile();
write_file(root, "Assets/A/Lib.asmdef", r#"{"name":"Lib"}"#);
write_file(root, "Assets/A/Code.cs", "class Code {}\n");
let lockfile_path = root.join("Library/UnitySolutionGenerator/csproj.lock");
std::fs::create_dir_all(lockfile_path.parent().unwrap()).unwrap();
LockfileIO::write(&lf, lockfile_path.to_str().unwrap()).unwrap();
let g = SolutionGenerator::new();
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
std::fs::remove_dir_all(root.join("tpl/ios-editor")).unwrap();
assert!(!root.join("tpl/ios-editor/Lib.csproj").exists());
g.generate_from_lockfile(&opts(root, BuildPlatform::Ios, BuildConfig::Editor), &lf)
.unwrap();
assert!(root.join("tpl/ios-editor/Lib.csproj").exists());
}
#[test]
fn lock_fingerprint_short_circuits_rescan() {
let tmp = make_temp_root();
let root = tmp.path();
write_file(root, "Assets/A/Code.cs", "x");
let entries = unity_solution_generator::__test_only::build_entries(
root.to_str().unwrap(),
root.to_str().unwrap(),
&["Assets/A/Code.cs".to_string()],
&[],
);
assert!(!entries.is_empty());
assert!(unity_solution_generator::__test_only::is_valid(&entries));
std::thread::sleep(std::time::Duration::from_millis(20));
write_file(root, "Assets/A/Other.cs", "y");
assert!(!unity_solution_generator::__test_only::is_valid(&entries));
}
#[test]
fn lock_fingerprint_sentinel_invalidates_on_appearance() {
let tmp = make_temp_root();
let root = tmp.path();
let absent = root.join("Library/PackageCache");
let entries = unity_solution_generator::__test_only::build_entries(
root.to_str().unwrap(),
root.to_str().unwrap(),
&[],
&[],
);
let absent_str = absent.to_string_lossy().to_string();
let recorded = entries.iter().find(|(p, _)| p == &absent_str);
assert!(
recorded.is_some_and(|(_, m)| *m == 0),
"missing path must be recorded with mtime=0 sentinel; got {:?}",
recorded
);
assert!(unity_solution_generator::__test_only::is_valid(&entries));
std::fs::create_dir_all(&absent).unwrap();
assert!(!unity_solution_generator::__test_only::is_valid(&entries));
}
#[test]
fn rsp_has_no_refonly() {
use std::path::PathBuf;
let rsp = unity_solution_generator::__test_only::build_rsp(
"9.0",
&[],
&[],
&[],
&[],
&[PathBuf::from("/tmp/A.cs")],
"/tmp/out.dll",
false,
);
assert!(
!rsp.lines().any(|l| l.trim() == "/refonly"),
"rsp must not contain /refonly — it suppresses csc body diagnostics. rsp:\n{}",
rsp
);
assert!(
rsp.lines().any(|l| l.trim() == "/deterministic"),
"rsp must keep /deterministic for cascade-skip stability. rsp:\n{}",
rsp
);
}