Board presets: - anvil new --board mega (uno, mega, nano, nano-old, leonardo, micro) - anvil new --list-boards shows presets with compatible clones - FQBN and baud rate flow into .anvil.toml via template variables - Defaults to uno when --board is omitted Devices --clear: - anvil devices --clear deletes .anvil.local, reverts to auto-detect
1065 lines
34 KiB
Rust
1065 lines
34 KiB
Rust
use tempfile::TempDir;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use anvil::templates::{TemplateManager, TemplateContext};
|
|
use anvil::project::config::ProjectConfig;
|
|
|
|
// ============================================================================
|
|
// Template extraction tests
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_basic_template_extracts_all_expected_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "test_proj".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
assert!(count >= 16, "Expected at least 16 files, got {}", count);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_creates_sketch_directory() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "blink".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let sketch_dir = tmp.path().join("blink");
|
|
assert!(sketch_dir.is_dir(), "Sketch directory should exist");
|
|
|
|
let ino_file = sketch_dir.join("blink.ino");
|
|
assert!(ino_file.exists(), "Sketch .ino file should exist");
|
|
|
|
// Verify the .ino content has correct includes
|
|
let content = fs::read_to_string(&ino_file).unwrap();
|
|
assert!(
|
|
content.contains("blink_app.h"),
|
|
".ino should include project-specific app header"
|
|
);
|
|
assert!(
|
|
content.contains("hal_arduino.h"),
|
|
".ino should include hal_arduino.h"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_creates_hal_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "sensor".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
assert!(
|
|
tmp.path().join("lib/hal/hal.h").exists(),
|
|
"hal.h should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("lib/hal/hal_arduino.h").exists(),
|
|
"hal_arduino.h should exist"
|
|
);
|
|
|
|
// Verify hal.h defines the abstract Hal class
|
|
let hal_content = fs::read_to_string(tmp.path().join("lib/hal/hal.h")).unwrap();
|
|
assert!(
|
|
hal_content.contains("class Hal"),
|
|
"hal.h should define class Hal"
|
|
);
|
|
assert!(
|
|
hal_content.contains("virtual void pinMode"),
|
|
"hal.h should declare pinMode"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_creates_app_header() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "my_sensor".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let app_path = tmp.path().join("lib/app/my_sensor_app.h");
|
|
assert!(app_path.exists(), "App header should exist with project name");
|
|
|
|
let content = fs::read_to_string(&app_path).unwrap();
|
|
assert!(
|
|
content.contains("#include <hal.h>"),
|
|
"App header should include hal.h"
|
|
);
|
|
assert!(
|
|
content.contains("class BlinkApp"),
|
|
"App header should define BlinkApp class"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_creates_test_infrastructure() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "blink".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
assert!(
|
|
tmp.path().join("test/CMakeLists.txt").exists(),
|
|
"CMakeLists.txt should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/test_unit.cpp").exists(),
|
|
"test_unit.cpp should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/mocks/mock_hal.h").exists(),
|
|
"mock_hal.h should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/mocks/sim_hal.h").exists(),
|
|
"sim_hal.h should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/run_tests.sh").exists(),
|
|
"run_tests.sh should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/run_tests.bat").exists(),
|
|
"run_tests.bat should exist"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_test_file_references_correct_app() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "motor_ctrl".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let test_content = fs::read_to_string(
|
|
tmp.path().join("test/test_unit.cpp")
|
|
).unwrap();
|
|
assert!(
|
|
test_content.contains("motor_ctrl_app.h"),
|
|
"Test file should include project-specific app header"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_cmake_references_correct_project() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "my_bot".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let cmake = fs::read_to_string(
|
|
tmp.path().join("test/CMakeLists.txt")
|
|
).unwrap();
|
|
assert!(
|
|
cmake.contains("my_bot"),
|
|
"CMakeLists.txt should contain project name"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_creates_dot_files() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "blink".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
assert!(
|
|
tmp.path().join(".gitignore").exists(),
|
|
".gitignore should be created from _dot_ prefix"
|
|
);
|
|
assert!(
|
|
tmp.path().join(".editorconfig").exists(),
|
|
".editorconfig should be created"
|
|
);
|
|
assert!(
|
|
tmp.path().join(".clang-format").exists(),
|
|
".clang-format should be created"
|
|
);
|
|
assert!(
|
|
tmp.path().join(".vscode/settings.json").exists(),
|
|
".vscode/settings.json should be created"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_template_creates_readme() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "blink".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let readme = tmp.path().join("README.md");
|
|
assert!(readme.exists(), "README.md should exist");
|
|
|
|
let content = fs::read_to_string(&readme).unwrap();
|
|
assert!(content.contains("blink"), "README should contain project name");
|
|
}
|
|
|
|
// ============================================================================
|
|
// .anvil.toml config tests
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_template_creates_valid_config() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "blink".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// Should be loadable by ProjectConfig
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.project.name, "blink");
|
|
assert_eq!(config.build.fqbn, "arduino:avr:uno");
|
|
assert_eq!(config.monitor.baud, 115200);
|
|
assert!(config.build.extra_flags.contains(&"-Werror".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_roundtrip() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("roundtrip_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.project.name, "roundtrip_test");
|
|
assert_eq!(loaded.build.fqbn, config.build.fqbn);
|
|
assert_eq!(loaded.monitor.baud, config.monitor.baud);
|
|
assert_eq!(loaded.build.include_dirs, config.build.include_dirs);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_find_project_root_walks_up() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("walk_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Create nested subdirectory
|
|
let deep = tmp.path().join("sketch").join("src").join("deep");
|
|
fs::create_dir_all(&deep).unwrap();
|
|
|
|
let found = ProjectConfig::find_project_root(&deep).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]
|
|
fn test_config_resolve_include_flags() {
|
|
let tmp = TempDir::new().unwrap();
|
|
fs::create_dir_all(tmp.path().join("lib/hal")).unwrap();
|
|
fs::create_dir_all(tmp.path().join("lib/app")).unwrap();
|
|
|
|
let config = ProjectConfig::new("flags_test");
|
|
let flags = config.resolve_include_flags(tmp.path());
|
|
|
|
assert_eq!(flags.len(), 2);
|
|
assert!(flags[0].starts_with("-I"));
|
|
assert!(flags[0].ends_with("lib/hal") || flags[0].ends_with("lib\\hal"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_skips_nonexistent_include_dirs() {
|
|
let tmp = TempDir::new().unwrap();
|
|
// Don't create the directories
|
|
let config = ProjectConfig::new("missing_dirs");
|
|
let flags = config.resolve_include_flags(tmp.path());
|
|
assert_eq!(flags.len(), 0, "Should skip non-existent directories");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Full project creation test (end-to-end in temp dir)
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_full_project_structure() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "full_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// Verify the complete expected file tree
|
|
let expected_files = vec![
|
|
".anvil.toml",
|
|
".gitignore",
|
|
".editorconfig",
|
|
".clang-format",
|
|
".vscode/settings.json",
|
|
"README.md",
|
|
"full_test/full_test.ino",
|
|
"lib/hal/hal.h",
|
|
"lib/hal/hal_arduino.h",
|
|
"lib/app/full_test_app.h",
|
|
"build.sh",
|
|
"build.bat",
|
|
"upload.sh",
|
|
"upload.bat",
|
|
"monitor.sh",
|
|
"monitor.bat",
|
|
"_detect_port.ps1",
|
|
"test/CMakeLists.txt",
|
|
"test/test_unit.cpp",
|
|
"test/run_tests.sh",
|
|
"test/run_tests.bat",
|
|
"test/mocks/mock_hal.h",
|
|
"test/mocks/sim_hal.h",
|
|
];
|
|
|
|
for f in &expected_files {
|
|
let p = tmp.path().join(f);
|
|
assert!(
|
|
p.exists(),
|
|
"Expected file missing: {} (checked {})",
|
|
f,
|
|
p.display()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_unicode_in_template_output() {
|
|
// Eric's rule: only ASCII characters
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "ascii_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// Check all generated text files for non-ASCII
|
|
check_ascii_recursive(tmp.path());
|
|
}
|
|
|
|
fn check_ascii_recursive(dir: &Path) {
|
|
for entry in fs::read_dir(dir).unwrap() {
|
|
let entry = entry.unwrap();
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
check_ascii_recursive(&path);
|
|
} else {
|
|
// Only check text files
|
|
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
|
|
if matches!(ext, "h" | "cpp" | "ino" | "txt" | "sh" | "bat" | "json" | "toml" | "md") {
|
|
let content = fs::read_to_string(&path).unwrap();
|
|
for (line_num, line) in content.lines().enumerate() {
|
|
for (col, ch) in line.chars().enumerate() {
|
|
assert!(
|
|
ch.is_ascii(),
|
|
"Non-ASCII character '{}' (U+{:04X}) at {}:{}:{} ",
|
|
ch,
|
|
ch as u32,
|
|
path.display(),
|
|
line_num + 1,
|
|
col + 1
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Error case tests
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_unknown_template_fails() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
let result = TemplateManager::extract("nonexistent", tmp.path(), &ctx);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_config_from_nonproject_fails() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let result = ProjectConfig::load(tmp.path());
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
// ============================================================================
|
|
// Self-contained script tests
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_template_creates_self_contained_scripts() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "standalone".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// All six scripts must exist
|
|
let scripts = vec![
|
|
"build.sh", "build.bat",
|
|
"upload.sh", "upload.bat",
|
|
"monitor.sh", "monitor.bat",
|
|
];
|
|
for script in &scripts {
|
|
let p = tmp.path().join(script);
|
|
assert!(p.exists(), "Script missing: {}", script);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_sh_reads_anvil_toml() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "toml_reader".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join("build.sh")).unwrap();
|
|
assert!(
|
|
content.contains(".anvil.toml"),
|
|
"build.sh should reference .anvil.toml"
|
|
);
|
|
assert!(
|
|
content.contains("arduino-cli"),
|
|
"build.sh should invoke arduino-cli"
|
|
);
|
|
assert!(
|
|
!content.contains("anvil build"),
|
|
"build.sh must NOT depend on the anvil binary"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_upload_sh_reads_anvil_toml() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "uploader".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join("upload.sh")).unwrap();
|
|
assert!(
|
|
content.contains(".anvil.toml"),
|
|
"upload.sh should reference .anvil.toml"
|
|
);
|
|
assert!(
|
|
content.contains("arduino-cli"),
|
|
"upload.sh should invoke arduino-cli"
|
|
);
|
|
assert!(
|
|
content.contains("upload"),
|
|
"upload.sh should contain upload command"
|
|
);
|
|
assert!(
|
|
content.contains("--monitor"),
|
|
"upload.sh should support --monitor flag"
|
|
);
|
|
assert!(
|
|
!content.contains("anvil upload"),
|
|
"upload.sh must NOT depend on the anvil binary"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_monitor_sh_reads_anvil_toml() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "serial_mon".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join("monitor.sh")).unwrap();
|
|
assert!(
|
|
content.contains(".anvil.toml"),
|
|
"monitor.sh should reference .anvil.toml"
|
|
);
|
|
assert!(
|
|
content.contains("--watch"),
|
|
"monitor.sh should support --watch flag"
|
|
);
|
|
assert!(
|
|
!content.contains("anvil monitor"),
|
|
"monitor.sh must NOT depend on the anvil binary"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scripts_have_shebangs() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "shebangs".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
for script in &["build.sh", "upload.sh", "monitor.sh", "test/run_tests.sh"] {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.starts_with("#!/"),
|
|
"{} should start with a shebang line",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_scripts_no_anvil_binary_dependency() {
|
|
// Critical: generated projects must NOT require the anvil binary
|
|
// for build, upload, or monitor operations.
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "no_anvil_dep".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let scripts = vec![
|
|
"build.sh", "build.bat",
|
|
"upload.sh", "upload.bat",
|
|
"monitor.sh", "monitor.bat",
|
|
"test/run_tests.sh", "test/run_tests.bat",
|
|
];
|
|
|
|
for script in &scripts {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
// None of these scripts should shell out to anvil
|
|
let has_anvil_cmd = content.lines().any(|line| {
|
|
let trimmed = line.trim();
|
|
// 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!(
|
|
!has_anvil_cmd,
|
|
"{} should not invoke the anvil binary (project must be self-contained)",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_gitignore_excludes_build_cache() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "gitcheck".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(".gitignore")).unwrap();
|
|
assert!(
|
|
content.contains(".build/"),
|
|
".gitignore should exclude .build/ (arduino-cli build cache)"
|
|
);
|
|
assert!(
|
|
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]
|
|
fn test_readme_documents_self_contained_workflow() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "docs_check".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let readme = fs::read_to_string(tmp.path().join("README.md")).unwrap();
|
|
assert!(
|
|
readme.contains("./build.sh"),
|
|
"README should document build.sh"
|
|
);
|
|
assert!(
|
|
readme.contains("./upload.sh"),
|
|
"README should document upload.sh"
|
|
);
|
|
assert!(
|
|
readme.contains("./monitor.sh"),
|
|
"README should document monitor.sh"
|
|
);
|
|
assert!(
|
|
readme.contains("self-contained"),
|
|
"README should mention self-contained"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_scripts_tolerate_missing_toml_keys() {
|
|
// Regression: toml_get must not kill the script when a key is absent.
|
|
// With set -euo pipefail, bare grep returns exit 1 on no match,
|
|
// pipefail propagates it, and set -e terminates silently.
|
|
// Every grep in toml_get/toml_array must have "|| true".
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "grep_safe".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
for script in &["build.sh", "upload.sh", "monitor.sh"] {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
|
|
// If the script uses set -e (or -euo pipefail), then every
|
|
// toml_get/toml_array function must guard grep with || true
|
|
if content.contains("set -e") || content.contains("set -euo") {
|
|
// Find the toml_get function body and check for || true
|
|
let has_safe_grep = content.contains("|| true");
|
|
assert!(
|
|
has_safe_grep,
|
|
"{} uses set -e but toml_get/toml_array lacks '|| true' guard. \
|
|
Missing TOML keys will silently kill the script.",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// 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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Board preset tests
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_board_preset_fqbn_in_config() {
|
|
// Creating a project with --board mega should set the FQBN in .anvil.toml
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "mega_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(
|
|
config.build.fqbn, "arduino:avr:mega:cpu=atmega2560",
|
|
".anvil.toml should contain mega FQBN"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_board_preset_custom_fqbn_in_config() {
|
|
// Even arbitrary FQBNs should work through the template
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "custom_board".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
fqbn: "esp32:esp32:esp32".to_string(),
|
|
baud: 9600,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.build.fqbn, "esp32:esp32:esp32");
|
|
assert_eq!(config.monitor.baud, 9600);
|
|
} |