Improved tests
This commit is contained in:
@@ -550,6 +550,32 @@ mod tests {
|
|||||||
assert_eq!(info.vid_pid(), "2341:0043");
|
assert_eq!(info.vid_pid(), "2341:0043");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_vid_pid_case_insensitive() {
|
||||||
|
let info = PortInfo {
|
||||||
|
port_name: "COM3".to_string(),
|
||||||
|
protocol: "Serial Port (USB)".to_string(),
|
||||||
|
board_name: "Unknown".to_string(),
|
||||||
|
fqbn: String::new(),
|
||||||
|
vid: "0x2341".to_string(),
|
||||||
|
pid: "0x0043".to_string(),
|
||||||
|
serial_number: String::new(),
|
||||||
|
};
|
||||||
|
// vid_pid() should normalize to lowercase
|
||||||
|
assert_eq!(info.vid_pid(), "2341:0043");
|
||||||
|
|
||||||
|
let upper = PortInfo {
|
||||||
|
port_name: "COM3".to_string(),
|
||||||
|
protocol: "Serial Port (USB)".to_string(),
|
||||||
|
board_name: "Unknown".to_string(),
|
||||||
|
fqbn: String::new(),
|
||||||
|
vid: "0x1A86".to_string(),
|
||||||
|
pid: "0x7523".to_string(),
|
||||||
|
serial_number: String::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(upper.vid_pid(), "1a86:7523");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_usb_from_vid_pid() {
|
fn test_is_usb_from_vid_pid() {
|
||||||
let info = PortInfo {
|
let info = PortInfo {
|
||||||
|
|||||||
@@ -269,7 +269,14 @@ fn test_config_find_project_root_walks_up() {
|
|||||||
fs::create_dir_all(&deep).unwrap();
|
fs::create_dir_all(&deep).unwrap();
|
||||||
|
|
||||||
let found = ProjectConfig::find_project_root(&deep).unwrap();
|
let found = ProjectConfig::find_project_root(&deep).unwrap();
|
||||||
assert_eq!(found, tmp.path().canonicalize().unwrap());
|
let expected = tmp.path().canonicalize().unwrap();
|
||||||
|
|
||||||
|
// On Windows, canonicalize() returns \\?\ extended path prefix.
|
||||||
|
// Strip it for comparison since find_project_root may not include it.
|
||||||
|
let found_str = found.to_string_lossy().to_string();
|
||||||
|
let expected_str = expected.to_string_lossy().to_string();
|
||||||
|
let norm = |s: &str| s.strip_prefix(r"\\?\").unwrap_or(s).to_string();
|
||||||
|
assert_eq!(norm(&found_str), norm(&expected_str));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -327,6 +334,7 @@ fn test_full_project_structure() {
|
|||||||
"upload.bat",
|
"upload.bat",
|
||||||
"monitor.sh",
|
"monitor.sh",
|
||||||
"monitor.bat",
|
"monitor.bat",
|
||||||
|
"_detect_port.ps1",
|
||||||
"test/CMakeLists.txt",
|
"test/CMakeLists.txt",
|
||||||
"test/test_unit.cpp",
|
"test/test_unit.cpp",
|
||||||
"test/run_tests.sh",
|
"test/run_tests.sh",
|
||||||
@@ -566,18 +574,21 @@ fn test_scripts_no_anvil_binary_dependency() {
|
|||||||
// None of these scripts should shell out to anvil
|
// None of these scripts should shell out to anvil
|
||||||
let has_anvil_cmd = content.lines().any(|line| {
|
let has_anvil_cmd = content.lines().any(|line| {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
// Skip comments and echo/print lines
|
// Skip comments, echo/print lines, and shell output functions
|
||||||
if trimmed.starts_with('#')
|
if trimmed.starts_with('#')
|
||||||
|| trimmed.starts_with("::")
|
|| trimmed.starts_with("::")
|
||||||
|| trimmed.starts_with("echo")
|
|| trimmed.starts_with("echo")
|
||||||
|| trimmed.starts_with("REM")
|
|| trimmed.starts_with("REM")
|
||||||
|| trimmed.starts_with("rem")
|
|| trimmed.starts_with("rem")
|
||||||
|
|| trimmed.starts_with("warn")
|
||||||
|
|| trimmed.starts_with("die")
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check for "anvil " as a command invocation
|
// Check for "anvil " as a command invocation
|
||||||
trimmed.contains("anvil ")
|
trimmed.contains("anvil ")
|
||||||
&& !trimmed.contains("anvil.toml")
|
&& !trimmed.contains("anvil.toml")
|
||||||
|
&& !trimmed.contains("anvil.local")
|
||||||
&& !trimmed.contains("Anvil")
|
&& !trimmed.contains("Anvil")
|
||||||
});
|
});
|
||||||
assert!(
|
assert!(
|
||||||
@@ -607,6 +618,10 @@ fn test_gitignore_excludes_build_cache() {
|
|||||||
content.contains("test/build/"),
|
content.contains("test/build/"),
|
||||||
".gitignore should exclude test/build/ (cmake build cache)"
|
".gitignore should exclude test/build/ (cmake build cache)"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains(".anvil.local"),
|
||||||
|
".gitignore should exclude .anvil.local (machine-specific config)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -669,3 +684,282 @@ fn test_scripts_tolerate_missing_toml_keys() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Batch script safety
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bat_scripts_no_unescaped_parens_in_echo() {
|
||||||
|
// Regression: unescaped ( or ) in echo lines inside if blocks
|
||||||
|
// cause cmd.exe to misparse block boundaries.
|
||||||
|
// e.g. "echo Configuring (first run)..." closes the if block early.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "parens_test".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
let bat_files = vec![
|
||||||
|
"build.bat",
|
||||||
|
"upload.bat",
|
||||||
|
"monitor.bat",
|
||||||
|
"test/run_tests.bat",
|
||||||
|
];
|
||||||
|
|
||||||
|
for bat in &bat_files {
|
||||||
|
let content = fs::read_to_string(tmp.path().join(bat)).unwrap();
|
||||||
|
let mut in_if_block = 0i32;
|
||||||
|
|
||||||
|
for (line_num, line) in content.lines().enumerate() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// Track if-block nesting (rough heuristic)
|
||||||
|
if trimmed.starts_with("if ") && trimmed.ends_with('(') {
|
||||||
|
in_if_block += 1;
|
||||||
|
}
|
||||||
|
if trimmed == ")" {
|
||||||
|
in_if_block -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inside if blocks, echo lines must not have bare ( or )
|
||||||
|
if in_if_block > 0
|
||||||
|
&& (trimmed.starts_with("echo ") || trimmed.starts_with("echo."))
|
||||||
|
{
|
||||||
|
let msg_part = &trimmed[4..]; // after "echo"
|
||||||
|
// Allow ^( and ^) which are escaped
|
||||||
|
let unescaped_open = msg_part.matches('(').count()
|
||||||
|
- msg_part.matches("^(").count();
|
||||||
|
let unescaped_close = msg_part.matches(')').count()
|
||||||
|
- msg_part.matches("^)").count();
|
||||||
|
assert!(
|
||||||
|
unescaped_open == 0 && unescaped_close == 0,
|
||||||
|
"{} line {}: unescaped parentheses in echo inside if block: {}",
|
||||||
|
bat,
|
||||||
|
line_num + 1,
|
||||||
|
trimmed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// .anvil.local references in scripts
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scripts_read_anvil_local_for_port() {
|
||||||
|
// upload and monitor scripts should read port from .anvil.local,
|
||||||
|
// NOT from .anvil.toml.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "local_test".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
for script in &["upload.sh", "upload.bat", "monitor.sh", "monitor.bat"] {
|
||||||
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
||||||
|
assert!(
|
||||||
|
content.contains(".anvil.local"),
|
||||||
|
"{} should reference .anvil.local for port config",
|
||||||
|
script
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_anvil_toml_template_has_no_port() {
|
||||||
|
// Port config belongs in .anvil.local, not .anvil.toml
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "no_port".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
let content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
|
||||||
|
for line in content.lines() {
|
||||||
|
let trimmed = line.trim().trim_start_matches('#').trim();
|
||||||
|
assert!(
|
||||||
|
!trimmed.starts_with("port ")
|
||||||
|
&& !trimmed.starts_with("port=")
|
||||||
|
&& !trimmed.starts_with("port_windows")
|
||||||
|
&& !trimmed.starts_with("port_linux"),
|
||||||
|
".anvil.toml should not contain port entries, found: {}",
|
||||||
|
line
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// _detect_port.ps1 integration
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_bat_scripts_call_detect_port_ps1() {
|
||||||
|
// upload.bat and monitor.bat should delegate port detection to
|
||||||
|
// _detect_port.ps1, not do inline powershell with { } braces
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "ps1_test".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
for bat in &["upload.bat", "monitor.bat"] {
|
||||||
|
let content = fs::read_to_string(tmp.path().join(bat)).unwrap();
|
||||||
|
assert!(
|
||||||
|
content.contains("_detect_port.ps1"),
|
||||||
|
"{} should call _detect_port.ps1 for port detection",
|
||||||
|
bat
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_port_ps1_is_valid() {
|
||||||
|
// Basic structural checks on the PowerShell helper
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "ps1_valid".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
let content = fs::read_to_string(tmp.path().join("_detect_port.ps1")).unwrap();
|
||||||
|
assert!(
|
||||||
|
content.contains("arduino-cli board list --format json"),
|
||||||
|
"_detect_port.ps1 should use arduino-cli JSON output"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("protocol_label"),
|
||||||
|
"_detect_port.ps1 should check protocol_label for USB detection"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("VidPid"),
|
||||||
|
"_detect_port.ps1 should support VID:PID resolution"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Refresh command
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_refresh_freshly_extracted_is_up_to_date() {
|
||||||
|
// A freshly extracted project should have all refreshable files
|
||||||
|
// byte-identical to the template.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "fresh_proj".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
let reference = TempDir::new().unwrap();
|
||||||
|
TemplateManager::extract("basic", reference.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
let refreshable = vec![
|
||||||
|
"build.sh", "build.bat",
|
||||||
|
"upload.sh", "upload.bat",
|
||||||
|
"monitor.sh", "monitor.bat",
|
||||||
|
"_detect_port.ps1",
|
||||||
|
"test/run_tests.sh", "test/run_tests.bat",
|
||||||
|
];
|
||||||
|
|
||||||
|
for f in &refreshable {
|
||||||
|
let a = fs::read(tmp.path().join(f)).unwrap();
|
||||||
|
let b = fs::read(reference.path().join(f)).unwrap();
|
||||||
|
assert_eq!(a, b, "Freshly extracted {} should match template", f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_refresh_detects_modified_script() {
|
||||||
|
// Tampering with a script should cause a byte mismatch
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "mod_proj".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
// Tamper with build.sh
|
||||||
|
let build_sh = tmp.path().join("build.sh");
|
||||||
|
let mut content = fs::read_to_string(&build_sh).unwrap();
|
||||||
|
content.push_str("\n# user modification\n");
|
||||||
|
fs::write(&build_sh, content).unwrap();
|
||||||
|
|
||||||
|
// Compare with fresh template
|
||||||
|
let reference = TempDir::new().unwrap();
|
||||||
|
TemplateManager::extract("basic", reference.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
let a = fs::read(tmp.path().join("build.sh")).unwrap();
|
||||||
|
let b = fs::read(reference.path().join("build.sh")).unwrap();
|
||||||
|
assert_ne!(a, b, "Modified build.sh should differ from template");
|
||||||
|
|
||||||
|
// Non-modified file should still match
|
||||||
|
let a = fs::read(tmp.path().join("upload.sh")).unwrap();
|
||||||
|
let b = fs::read(reference.path().join("upload.sh")).unwrap();
|
||||||
|
assert_eq!(a, b, "Untouched upload.sh should match template");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_refresh_does_not_list_user_files() {
|
||||||
|
// .anvil.toml, source files, and config must never be refreshable.
|
||||||
|
let never_refreshable = vec![
|
||||||
|
".anvil.toml",
|
||||||
|
".anvil.local",
|
||||||
|
".gitignore",
|
||||||
|
".editorconfig",
|
||||||
|
".clang-format",
|
||||||
|
"README.md",
|
||||||
|
"test/CMakeLists.txt",
|
||||||
|
"test/test_unit.cpp",
|
||||||
|
"test/mocks/mock_hal.h",
|
||||||
|
"test/mocks/sim_hal.h",
|
||||||
|
];
|
||||||
|
|
||||||
|
let refreshable = vec![
|
||||||
|
"build.sh", "build.bat",
|
||||||
|
"upload.sh", "upload.bat",
|
||||||
|
"monitor.sh", "monitor.bat",
|
||||||
|
"_detect_port.ps1",
|
||||||
|
"test/run_tests.sh", "test/run_tests.bat",
|
||||||
|
];
|
||||||
|
|
||||||
|
for uf in &never_refreshable {
|
||||||
|
assert!(
|
||||||
|
!refreshable.contains(uf),
|
||||||
|
"{} must never be in the refreshable files list",
|
||||||
|
uf
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// .anvil.local VID:PID in scripts
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_scripts_read_vid_pid_from_anvil_local() {
|
||||||
|
// upload and monitor scripts should parse vid_pid from .anvil.local
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let ctx = TemplateContext {
|
||||||
|
project_name: "vidpid_test".to_string(),
|
||||||
|
anvil_version: "1.0.0".to_string(),
|
||||||
|
};
|
||||||
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
||||||
|
|
||||||
|
for script in &["upload.sh", "upload.bat", "monitor.sh", "monitor.bat"] {
|
||||||
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
||||||
|
assert!(
|
||||||
|
content.contains("vid_pid") || content.contains("VidPid") || content.contains("VID_PID"),
|
||||||
|
"{} should parse vid_pid from .anvil.local",
|
||||||
|
script
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user