From d7c6d432f966361ad0dddcdbff21ca4f1ac4307d Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Wed, 18 Feb 2026 21:22:14 -0600 Subject: [PATCH] Improved tests --- src/board/mod.rs | 26 ++++ tests/integration_test.rs | 298 +++++++++++++++++++++++++++++++++++++- 2 files changed, 322 insertions(+), 2 deletions(-) diff --git a/src/board/mod.rs b/src/board/mod.rs index 1a552c7..98f7b73 100644 --- a/src/board/mod.rs +++ b/src/board/mod.rs @@ -550,6 +550,32 @@ mod tests { 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] fn test_is_usb_from_vid_pid() { let info = PortInfo { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 96d5979..1aaf0ec 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -269,7 +269,14 @@ fn test_config_find_project_root_walks_up() { fs::create_dir_all(&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] @@ -327,6 +334,7 @@ fn test_full_project_structure() { "upload.bat", "monitor.sh", "monitor.bat", + "_detect_port.ps1", "test/CMakeLists.txt", "test/test_unit.cpp", "test/run_tests.sh", @@ -566,18 +574,21 @@ fn test_scripts_no_anvil_binary_dependency() { // None of these scripts should shell out to anvil let has_anvil_cmd = content.lines().any(|line| { let trimmed = line.trim(); - // Skip comments and echo/print lines + // Skip comments, echo/print lines, and shell output functions if trimmed.starts_with('#') || trimmed.starts_with("::") || trimmed.starts_with("echo") || trimmed.starts_with("REM") || trimmed.starts_with("rem") + || trimmed.starts_with("warn") + || trimmed.starts_with("die") { return false; } // Check for "anvil " as a command invocation trimmed.contains("anvil ") && !trimmed.contains("anvil.toml") + && !trimmed.contains("anvil.local") && !trimmed.contains("Anvil") }); assert!( @@ -607,6 +618,10 @@ fn test_gitignore_excludes_build_cache() { content.contains("test/build/"), ".gitignore should exclude test/build/ (cmake build cache)" ); + assert!( + content.contains(".anvil.local"), + ".gitignore should exclude .anvil.local (machine-specific config)" + ); } #[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 + ); + } +} \ No newline at end of file