Complete Arduino API mock (mock_arduino.h/cpp) enabling application code to compile and run on PC without hardware. Includes MockSerial, String class, GPIO/analog/timing/interrupt mocks with state tracking and test control API. - Arduino.h, Wire.h, SPI.h shims intercept includes in test builds - System test template (test_system.cpp) using SimHal - CMakeLists.txt builds mock_arduino as static lib, links both suites - Root test.sh/test.bat with --unit/--system/--clean/--verbose flags - test.bat auto-detects MSVC via vswhere + vcvarsall.bat - Doctor reports nuanced compiler status (on PATH vs installed) - Refresh pulls mock infrastructure into existing projects - 15 tests passing: 7 unit (MockHal) + 8 system (SimHal)
3486 lines
116 KiB
Rust
3486 lines
116 KiB
Rust
use tempfile::TempDir;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use anvil::templates::{TemplateManager, TemplateContext};
|
|
use anvil::project::config::{
|
|
ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file,
|
|
};
|
|
|
|
// ============================================================================
|
|
// 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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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/mocks/mock_arduino.h").exists(),
|
|
"mock_arduino.h should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/mocks/mock_arduino.cpp").exists(),
|
|
"mock_arduino.cpp should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/mocks/Arduino.h").exists(),
|
|
"Arduino.h shim should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/mocks/Wire.h").exists(),
|
|
"Wire.h shim should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/mocks/SPI.h").exists(),
|
|
"SPI.h shim should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test/test_system.cpp").exists(),
|
|
"test_system.cpp should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test.sh").exists(),
|
|
"test.sh root script should exist"
|
|
);
|
|
assert!(
|
|
tmp.path().join("test.bat").exists(),
|
|
"test.bat root script 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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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.default, "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.default, config.build.default);
|
|
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");
|
|
}
|
|
|
|
// ============================================================================
|
|
// Config: default board and [boards.X] structure
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_config_default_points_to_boards_section() {
|
|
let config = ProjectConfig::new("test");
|
|
assert_eq!(config.build.default, "uno");
|
|
assert!(
|
|
config.boards.contains_key("uno"),
|
|
"boards map should contain the default board"
|
|
);
|
|
assert_eq!(config.boards["uno"].fqbn, "arduino:avr:uno");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_default_fqbn_resolves() {
|
|
let config = ProjectConfig::new("test");
|
|
let fqbn = config.default_fqbn().unwrap();
|
|
assert_eq!(fqbn, "arduino:avr:uno");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_default_board_returns_fqbn_and_baud() {
|
|
let config = ProjectConfig::new("test");
|
|
let (fqbn, baud) = config.default_board().unwrap();
|
|
assert_eq!(fqbn, "arduino:avr:uno");
|
|
assert_eq!(baud, 115200);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_resolve_board_named() {
|
|
let mut config = ProjectConfig::new("test");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: Some(57600),
|
|
});
|
|
let (fqbn, baud) = config.resolve_board("mega").unwrap();
|
|
assert_eq!(fqbn, "arduino:avr:mega:cpu=atmega2560");
|
|
assert_eq!(baud, 57600);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_resolve_board_inherits_baud() {
|
|
let mut config = ProjectConfig::new("test");
|
|
config.boards.insert("nano".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:nano".to_string(),
|
|
baud: None,
|
|
});
|
|
let (_, baud) = config.resolve_board("nano").unwrap();
|
|
assert_eq!(
|
|
baud, config.monitor.baud,
|
|
"Should inherit monitor baud when board has no override"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_resolve_board_unknown_fails() {
|
|
let config = ProjectConfig::new("test");
|
|
assert!(config.resolve_board("esp32").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_new_with_board_preset() {
|
|
let config = ProjectConfig::new_with_board(
|
|
"test", "mega", "arduino:avr:mega:cpu=atmega2560", 115200,
|
|
);
|
|
assert_eq!(config.build.default, "mega");
|
|
assert!(config.boards.contains_key("mega"));
|
|
assert_eq!(
|
|
config.boards["mega"].fqbn,
|
|
"arduino:avr:mega:cpu=atmega2560"
|
|
);
|
|
assert!(
|
|
!config.boards.contains_key("uno"),
|
|
"Should only contain the specified board"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_board_roundtrip() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("multi");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: Some(57600),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert!(loaded.boards.contains_key("mega"));
|
|
let mega = &loaded.boards["mega"];
|
|
assert_eq!(mega.fqbn, "arduino:avr:mega:cpu=atmega2560");
|
|
assert_eq!(mega.baud, Some(57600));
|
|
}
|
|
|
|
#[test]
|
|
fn test_toml_template_has_default_field() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "default_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
assert!(
|
|
content.contains("default = \"uno\""),
|
|
".anvil.toml should have default = \"uno\" in [build]"
|
|
);
|
|
assert!(
|
|
content.contains("[boards.uno]"),
|
|
".anvil.toml should have a [boards.uno] section"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_toml_template_no_fqbn_in_build_section() {
|
|
// New configs should NOT have fqbn directly in [build]
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "no_build_fqbn".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
|
|
let mut in_build = false;
|
|
for line in content.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed == "[build]" {
|
|
in_build = true;
|
|
continue;
|
|
}
|
|
if trimmed.starts_with('[') {
|
|
in_build = false;
|
|
}
|
|
if in_build && !trimmed.starts_with('#') {
|
|
assert!(
|
|
!trimmed.starts_with("fqbn"),
|
|
"[build] section should not contain fqbn; it belongs in [boards.X]"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Config migration: old format -> new format
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_migrate_old_config_adds_default_and_boards() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let old_config = "\
|
|
[project]\n\
|
|
name = \"hand\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
fqbn = \"arduino:avr:uno\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n\
|
|
\n\
|
|
[boards.micro]\n\
|
|
fqbn = \"arduino:avr:micro\"\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
|
|
// In-memory state
|
|
assert_eq!(config.build.default, "uno");
|
|
assert!(config.boards.contains_key("uno"));
|
|
assert_eq!(config.boards["uno"].fqbn, "arduino:avr:uno");
|
|
assert!(config.boards.contains_key("micro"));
|
|
|
|
// On-disk file rewritten
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
assert!(
|
|
content.contains("default = \"uno\""),
|
|
"Migrated file should contain default = \"uno\""
|
|
);
|
|
assert!(
|
|
content.contains("[boards.uno]"),
|
|
"Migrated file should add [boards.uno] section"
|
|
);
|
|
// Old fqbn kept for backward compat with pre-refresh scripts
|
|
assert!(
|
|
content.contains("fqbn = \"arduino:avr:uno\""),
|
|
"Migration should keep old fqbn for backward compat"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrate_preserves_comments() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let old_config = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
fqbn = \"arduino:avr:uno\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n\
|
|
# port = \"/dev/ttyUSB0\" # Uncomment to skip auto-detect\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap();
|
|
ProjectConfig::load(tmp.path()).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
assert!(
|
|
content.contains("# port = \"/dev/ttyUSB0\""),
|
|
"Migration should preserve comments"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrate_matches_preset_name() {
|
|
// A mega FQBN should get board name "mega", not "default"
|
|
let tmp = TempDir::new().unwrap();
|
|
let old_config = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
fqbn = \"arduino:avr:mega:cpu=atmega2560\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.build.default, "mega");
|
|
assert!(config.boards.contains_key("mega"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrate_unknown_fqbn_uses_default_name() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let old_config = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
fqbn = \"some:custom:board\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.build.default, "default");
|
|
assert!(config.boards.contains_key("default"));
|
|
assert_eq!(config.boards["default"].fqbn, "some:custom:board");
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrate_skips_if_already_has_default() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let new_config = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
default = \"uno\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n\
|
|
\n\
|
|
[boards.uno]\n\
|
|
fqbn = \"arduino:avr:uno\"\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), new_config).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.build.default, "uno");
|
|
|
|
// File should be unchanged
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
assert_eq!(
|
|
content, new_config,
|
|
"New-format config should not be modified"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrate_does_not_duplicate_board_section() {
|
|
// If [boards.uno] already exists, migration should not add a second one
|
|
let tmp = TempDir::new().unwrap();
|
|
let old_config = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
fqbn = \"arduino:avr:uno\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n\
|
|
\n\
|
|
[boards.uno]\n\
|
|
fqbn = \"arduino:avr:uno\"\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap();
|
|
ProjectConfig::load(tmp.path()).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
let count = content.matches("[boards.uno]").count();
|
|
assert_eq!(count, 1, "Should not duplicate [boards.uno] section");
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrate_default_appears_before_fqbn() {
|
|
// After migration, default = "..." should come before fqbn = "..."
|
|
// in [build] for natural reading order.
|
|
let tmp = TempDir::new().unwrap();
|
|
let old_config = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
fqbn = \"arduino:avr:uno\"\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n";
|
|
|
|
fs::write(tmp.path().join(CONFIG_FILENAME), old_config).unwrap();
|
|
ProjectConfig::load(tmp.path()).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
let default_pos = content.find("default = ").expect("should have default");
|
|
let fqbn_pos = content.find("fqbn = ").expect("should have fqbn");
|
|
assert!(
|
|
default_pos < fqbn_pos,
|
|
"default should appear before fqbn in [build] section"
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// set_default_in_file: text-based config editing
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_set_default_replaces_existing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let config_path = tmp.path().join(CONFIG_FILENAME);
|
|
let old = set_default_in_file(&config_path, "mega").unwrap();
|
|
assert_eq!(old, "uno");
|
|
|
|
let content = fs::read_to_string(&config_path).unwrap();
|
|
assert!(content.contains("default = \"mega\""));
|
|
assert!(!content.contains("default = \"uno\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_default_adds_when_missing() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let content = "\
|
|
[project]\n\
|
|
name = \"test\"\n\
|
|
anvil_version = \"1.0.0\"\n\
|
|
\n\
|
|
[build]\n\
|
|
warnings = \"more\"\n\
|
|
include_dirs = [\"lib/hal\", \"lib/app\"]\n\
|
|
extra_flags = [\"-Werror\"]\n\
|
|
\n\
|
|
[monitor]\n\
|
|
baud = 115200\n\
|
|
\n\
|
|
[boards.uno]\n\
|
|
fqbn = \"arduino:avr:uno\"\n";
|
|
|
|
let config_path = tmp.path().join(CONFIG_FILENAME);
|
|
fs::write(&config_path, content).unwrap();
|
|
|
|
let old = set_default_in_file(&config_path, "uno").unwrap();
|
|
assert_eq!(old, "", "Should return empty string when no previous default");
|
|
|
|
let result = fs::read_to_string(&config_path).unwrap();
|
|
assert!(result.contains("default = \"uno\""));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_default_is_idempotent() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let config_path = tmp.path().join(CONFIG_FILENAME);
|
|
|
|
set_default_in_file(&config_path, "mega").unwrap();
|
|
let content_after_first = fs::read_to_string(&config_path).unwrap();
|
|
|
|
set_default_in_file(&config_path, "mega").unwrap();
|
|
let content_after_second = fs::read_to_string(&config_path).unwrap();
|
|
|
|
assert_eq!(
|
|
content_after_first, content_after_second,
|
|
"Second set should not change file"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_default_preserves_other_fields() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let config_path = tmp.path().join(CONFIG_FILENAME);
|
|
set_default_in_file(&config_path, "mega").unwrap();
|
|
|
|
// Reload and check nothing else broke
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.build.default, "mega");
|
|
assert_eq!(loaded.build.warnings, "more");
|
|
assert_eq!(loaded.project.name, "test");
|
|
assert_eq!(loaded.monitor.baud, 115200);
|
|
assert!(loaded.build.include_dirs.contains(&"lib/hal".to_string()));
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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(),
|
|
board_name: "uno".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",
|
|
"_monitor_filter.ps1",
|
|
"test/CMakeLists.txt",
|
|
"test/test_unit.cpp",
|
|
"test/test_system.cpp",
|
|
"test/run_tests.sh",
|
|
"test/run_tests.bat",
|
|
"test/mocks/mock_hal.h",
|
|
"test/mocks/sim_hal.h",
|
|
"test/mocks/mock_arduino.h",
|
|
"test/mocks/mock_arduino.cpp",
|
|
"test/mocks/Arduino.h",
|
|
"test/mocks/Wire.h",
|
|
"test/mocks/SPI.h",
|
|
"test.sh",
|
|
"test.bat",
|
|
];
|
|
|
|
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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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(),
|
|
board_name: "uno".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",
|
|
"test.sh", "test.bat",
|
|
"_detect_port.ps1",
|
|
"_monitor_filter.ps1",
|
|
"test/run_tests.sh", "test/run_tests.bat",
|
|
"test/CMakeLists.txt",
|
|
"test/mocks/mock_arduino.h",
|
|
"test/mocks/mock_arduino.cpp",
|
|
"test/mocks/Arduino.h",
|
|
"test/mocks/Wire.h",
|
|
"test/mocks/SPI.h",
|
|
"test/mocks/mock_hal.h",
|
|
"test/mocks/sim_hal.h",
|
|
];
|
|
|
|
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(),
|
|
board_name: "uno".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 user test code must never be refreshable.
|
|
let never_refreshable = vec![
|
|
".anvil.toml",
|
|
".anvil.local",
|
|
".gitignore",
|
|
".editorconfig",
|
|
".clang-format",
|
|
"README.md",
|
|
"test/test_unit.cpp",
|
|
"test/test_system.cpp",
|
|
];
|
|
|
|
let refreshable = vec![
|
|
"build.sh", "build.bat",
|
|
"upload.sh", "upload.bat",
|
|
"monitor.sh", "monitor.bat",
|
|
"test.sh", "test.bat",
|
|
"_detect_port.ps1",
|
|
"_monitor_filter.ps1",
|
|
"test/run_tests.sh", "test/run_tests.bat",
|
|
"test/CMakeLists.txt",
|
|
"test/mocks/mock_arduino.h",
|
|
"test/mocks/mock_arduino.cpp",
|
|
"test/mocks/Arduino.h",
|
|
"test/mocks/Wire.h",
|
|
"test/mocks/SPI.h",
|
|
"test/mocks/mock_hal.h",
|
|
"test/mocks/sim_hal.h",
|
|
];
|
|
|
|
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(),
|
|
board_name: "uno".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(),
|
|
board_name: "mega".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.default, "mega",
|
|
".anvil.toml should have default = mega"
|
|
);
|
|
assert!(
|
|
config.boards.contains_key("mega"),
|
|
".anvil.toml should have [boards.mega]"
|
|
);
|
|
assert_eq!(
|
|
config.boards["mega"].fqbn, "arduino:avr:mega:cpu=atmega2560",
|
|
"[boards.mega] should have the correct 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(),
|
|
board_name: "esp".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.default, "esp");
|
|
assert_eq!(config.monitor.baud, 9600);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Multi-board profile tests
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_scripts_accept_board_flag() {
|
|
// All build/upload/monitor scripts should accept --board
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "multi_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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",
|
|
];
|
|
|
|
for script in &scripts {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.contains("--board"),
|
|
"{} should accept --board flag",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_sh_scripts_have_toml_section_get() {
|
|
// Shell scripts need section-aware TOML parsing for board profiles
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "section_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
assert!(
|
|
content.contains("toml_section_get"),
|
|
"{} should have toml_section_get function for board profiles",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_bat_scripts_have_section_parser() {
|
|
// Batch scripts need section-aware TOML parsing for board profiles
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "bat_section".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
for bat in &["build.bat", "upload.bat", "monitor.bat"] {
|
|
let content = fs::read_to_string(tmp.path().join(bat)).unwrap();
|
|
assert!(
|
|
content.contains("BOARD_SECTION") || content.contains("IN_SECTION"),
|
|
"{} should have section parser for board profiles",
|
|
bat
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_toml_template_has_board_profile_comments() {
|
|
// The generated .anvil.toml should include commented examples
|
|
// showing how to add board profiles
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "comment_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
assert!(
|
|
content.contains("[boards.mega]") || content.contains("boards.mega"),
|
|
".anvil.toml should show board profile examples in comments"
|
|
);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Scripts read default board from [build] section
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_scripts_read_default_board() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "default_read".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
assert!(
|
|
content.contains("DEFAULT_BOARD") && content.contains("'default'"),
|
|
"{} should read 'default' field from .anvil.toml into DEFAULT_BOARD",
|
|
script
|
|
);
|
|
assert!(
|
|
content.contains("ACTIVE_BOARD"),
|
|
"{} should resolve ACTIVE_BOARD from DEFAULT_BOARD or --board flag",
|
|
script
|
|
);
|
|
}
|
|
|
|
for bat in &["build.bat", "upload.bat", "monitor.bat"] {
|
|
let content = fs::read_to_string(tmp.path().join(bat)).unwrap();
|
|
assert!(
|
|
content.contains("DEFAULT_BOARD"),
|
|
"{} should read default field into DEFAULT_BOARD",
|
|
bat
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// USB_VID/USB_PID fix: compiler.cpp.extra_flags (not build.extra_flags)
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_scripts_use_compiler_extra_flags_not_build() {
|
|
// Regression: build.extra_flags clobbers board defaults (USB_VID, USB_PID)
|
|
// on ATmega32U4 boards (Micro, Leonardo). Scripts must use
|
|
// compiler.cpp.extra_flags and compiler.c.extra_flags instead,
|
|
// which are additive.
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "usb_fix".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let compile_scripts = vec![
|
|
"build.sh", "build.bat",
|
|
"upload.sh", "upload.bat",
|
|
];
|
|
|
|
for script in &compile_scripts {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
!content.contains("build.extra_flags"),
|
|
"{} must NOT use build.extra_flags (clobbers USB_VID/USB_PID on ATmega32U4)",
|
|
script
|
|
);
|
|
assert!(
|
|
content.contains("compiler.cpp.extra_flags"),
|
|
"{} should use compiler.cpp.extra_flags (additive, USB-safe)",
|
|
script
|
|
);
|
|
assert!(
|
|
content.contains("compiler.c.extra_flags"),
|
|
"{} should use compiler.c.extra_flags (additive, USB-safe)",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_monitor_scripts_have_no_compile_flags() {
|
|
// monitor.sh and monitor.bat should NOT contain any compile flags
|
|
// since they don't compile anything.
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "mon_flags".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
for script in &["monitor.sh", "monitor.bat"] {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
!content.contains("compiler.cpp.extra_flags")
|
|
&& !content.contains("compiler.c.extra_flags")
|
|
&& !content.contains("build.extra_flags"),
|
|
"{} should not contain any compile flags",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Script error messages: helpful guidance without requiring anvil
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_script_errors_show_manual_fix() {
|
|
// Error messages should explain how to fix .anvil.toml by hand,
|
|
// since the project is self-contained and anvil may not be installed.
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "err_msg".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let all_scripts = vec![
|
|
"build.sh", "build.bat",
|
|
"upload.sh", "upload.bat",
|
|
"monitor.sh", "monitor.bat",
|
|
];
|
|
|
|
for script in &all_scripts {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.contains("default = "),
|
|
"{} error messages should show the manual fix (default = \"...\")",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_script_errors_mention_arduino_cli() {
|
|
// Error messages should mention arduino-cli board listall as a
|
|
// discovery option, since the project doesn't require anvil.
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "cli_err".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let all_scripts = vec![
|
|
"build.sh", "build.bat",
|
|
"upload.sh", "upload.bat",
|
|
"monitor.sh", "monitor.bat",
|
|
];
|
|
|
|
for script in &all_scripts {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.contains("arduino-cli board listall"),
|
|
"{} error messages should mention 'arduino-cli board listall' for board discovery",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_script_errors_mention_toml_section_syntax() {
|
|
// The "board not found" error in build/upload scripts should show
|
|
// the [boards.X] section syntax so users know exactly what to add.
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "section_err".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// build and upload scripts have both no-default and board-not-found errors
|
|
for script in &["build.sh", "build.bat", "upload.sh", "upload.bat"] {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.contains("[boards."),
|
|
"{} board-not-found error should show [boards.X] section syntax",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Monitor: --timestamps and --log flags
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_monitor_scripts_accept_timestamps_flag() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "ts_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
for script in &["monitor.sh", "monitor.bat"] {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.contains("--timestamps"),
|
|
"{} should accept --timestamps flag",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_monitor_scripts_accept_log_flag() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "log_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
for script in &["monitor.sh", "monitor.bat"] {
|
|
let content = fs::read_to_string(tmp.path().join(script)).unwrap();
|
|
assert!(
|
|
content.contains("--log"),
|
|
"{} should accept --log flag for file output",
|
|
script
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_monitor_sh_has_timestamp_format() {
|
|
// The timestamp format should include hours, minutes, seconds, and millis
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "ts_fmt".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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("%H:%M:%S"),
|
|
"monitor.sh should use HH:MM:SS timestamp format"
|
|
);
|
|
assert!(
|
|
content.contains("%3N"),
|
|
"monitor.sh should include milliseconds in timestamps"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_monitor_sh_timestamps_work_in_watch_mode() {
|
|
// The timestamp filter should also apply in --watch mode
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "watch_ts".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
|
|
// The filter function should be called in the watch loop
|
|
assert!(
|
|
content.contains("monitor_filter"),
|
|
"monitor.sh should use a filter function for timestamps"
|
|
);
|
|
|
|
// Count usages of monitor_filter - should appear in both watch and non-watch
|
|
let filter_count = content.matches("monitor_filter").count();
|
|
assert!(
|
|
filter_count >= 3,
|
|
"monitor_filter should be defined and used in both watch and normal mode (found {} refs)",
|
|
filter_count
|
|
);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin map data
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_pinmap_exists_for_all_presets() {
|
|
use anvil::board::presets;
|
|
use anvil::board::pinmap;
|
|
|
|
// Every board preset (except nano-old) should have a pin map
|
|
for preset in presets::PRESETS {
|
|
let found = pinmap::find_pinmap_fuzzy(preset.name);
|
|
assert!(
|
|
found.is_some(),
|
|
"Board preset '{}' has no pin map. Add one to pinmap.rs.",
|
|
preset.name
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_uno_basics() {
|
|
use anvil::board::pinmap;
|
|
|
|
let m = pinmap::find_pinmap("uno").unwrap();
|
|
assert_eq!(m.total_digital, 14);
|
|
assert_eq!(m.total_analog, 6);
|
|
// Uno has 20 pins total (D0-D13 + A0-A5 mapped as D14-D19)
|
|
assert_eq!(m.pins.len(), 20);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_mega_basics() {
|
|
use anvil::board::pinmap;
|
|
|
|
let m = pinmap::find_pinmap("mega").unwrap();
|
|
assert_eq!(m.total_digital, 54);
|
|
assert_eq!(m.total_analog, 16);
|
|
// Mega has 70 pins: D0-D53 + A0-A15
|
|
assert_eq!(m.pins.len(), 70);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_resolve_alias_a0() {
|
|
use anvil::board::pinmap;
|
|
|
|
let uno = pinmap::find_pinmap("uno").unwrap();
|
|
assert_eq!(pinmap::resolve_alias(uno, "A0"), Some(14));
|
|
assert_eq!(pinmap::resolve_alias(uno, "a0"), Some(14));
|
|
|
|
let mega = pinmap::find_pinmap("mega").unwrap();
|
|
assert_eq!(pinmap::resolve_alias(mega, "A0"), Some(54));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_spi_pins_differ_by_board() {
|
|
use anvil::board::pinmap;
|
|
|
|
let uno = pinmap::find_pinmap("uno").unwrap();
|
|
let mega = pinmap::find_pinmap("mega").unwrap();
|
|
|
|
let uno_spi = uno.groups.iter().find(|g| g.name == "spi").unwrap();
|
|
let mega_spi = mega.groups.iter().find(|g| g.name == "spi").unwrap();
|
|
|
|
let uno_sck = uno_spi.fixed_pins.iter().find(|p| p.0 == "sck").unwrap().1;
|
|
let mega_sck = mega_spi.fixed_pins.iter().find(|p| p.0 == "sck").unwrap().1;
|
|
|
|
// Uno SCK=13, Mega SCK=52
|
|
assert_ne!(uno_sck, mega_sck, "SPI SCK should differ between uno and mega");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_capabilities_filter() {
|
|
use anvil::board::pinmap;
|
|
|
|
let m = pinmap::find_pinmap("uno").unwrap();
|
|
let pwm = pinmap::pins_with_capability(m, "pwm");
|
|
// Uno has PWM on 3, 5, 6, 9, 10, 11
|
|
assert_eq!(pwm.len(), 6);
|
|
|
|
let analog = pinmap::pins_with_capability(m, "analog");
|
|
assert_eq!(analog.len(), 6); // A0-A5
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_board_capabilities_list() {
|
|
use anvil::board::pinmap;
|
|
|
|
let m = pinmap::find_pinmap("uno").unwrap();
|
|
let caps = pinmap::board_capabilities(m);
|
|
assert!(caps.contains(&"digital"));
|
|
assert!(caps.contains(&"analog"));
|
|
assert!(caps.contains(&"pwm"));
|
|
assert!(caps.contains(&"spi"));
|
|
assert!(caps.contains(&"i2c"));
|
|
assert!(caps.contains(&"uart"));
|
|
assert!(caps.contains(&"interrupt"));
|
|
assert!(caps.contains(&"led"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_mega_has_four_uarts() {
|
|
use anvil::board::pinmap;
|
|
|
|
let m = pinmap::find_pinmap("mega").unwrap();
|
|
let uart_count = m.groups.iter()
|
|
.filter(|g| g.name.starts_with("uart"))
|
|
.count();
|
|
assert_eq!(uart_count, 4, "Mega should have uart, uart1, uart2, uart3");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_leonardo_i2c_different_from_uno() {
|
|
use anvil::board::pinmap;
|
|
|
|
let uno = pinmap::find_pinmap("uno").unwrap();
|
|
let leo = pinmap::find_pinmap("leonardo").unwrap();
|
|
|
|
let uno_i2c = uno.groups.iter().find(|g| g.name == "i2c").unwrap();
|
|
let leo_i2c = leo.groups.iter().find(|g| g.name == "i2c").unwrap();
|
|
|
|
let uno_sda = uno_i2c.fixed_pins.iter().find(|p| p.0 == "sda").unwrap().1;
|
|
let leo_sda = leo_i2c.fixed_pins.iter().find(|p| p.0 == "sda").unwrap().1;
|
|
|
|
// Uno SDA=18 (A4), Leonardo SDA=2
|
|
assert_ne!(uno_sda, leo_sda, "I2C SDA should differ between uno and leonardo");
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_spi_has_user_selectable_cs() {
|
|
use anvil::board::pinmap;
|
|
|
|
// All boards with SPI should require user to select CS
|
|
for board in pinmap::available_boards() {
|
|
let m = pinmap::find_pinmap(board).unwrap();
|
|
if let Some(spi) = m.groups.iter().find(|g| g.name == "spi") {
|
|
assert!(
|
|
spi.user_selectable.contains(&"cs"),
|
|
"Board '{}' SPI should have user-selectable CS pin",
|
|
board
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_pinmap_nano_has_analog_only_pins() {
|
|
use anvil::board::pinmap;
|
|
|
|
let m = pinmap::find_pinmap("nano").unwrap();
|
|
let a6 = pinmap::get_pin(m, 20).unwrap(); // A6
|
|
assert!(a6.capabilities.contains(&"analog"));
|
|
assert!(!a6.capabilities.contains(&"digital"),
|
|
"Nano A6 should be analog-only");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin config in .anvil.toml
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_config_with_pins_roundtrips() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment, BusConfig};
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("pin_test");
|
|
|
|
// Add pin assignments
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("red_led".to_string(), PinAssignment {
|
|
pin: 13,
|
|
mode: "output".to_string(),
|
|
});
|
|
assignments.insert("limit_switch".to_string(), PinAssignment {
|
|
pin: 7,
|
|
mode: "input".to_string(),
|
|
});
|
|
|
|
let mut buses = HashMap::new();
|
|
let mut spi_pins = HashMap::new();
|
|
spi_pins.insert("cs".to_string(), 10u8);
|
|
buses.insert("spi".to_string(), BusConfig { user_pins: spi_pins });
|
|
buses.insert("i2c".to_string(), BusConfig { user_pins: HashMap::new() });
|
|
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments,
|
|
buses,
|
|
});
|
|
|
|
config.save(tmp.path()).unwrap();
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert_eq!(pc.assignments.len(), 2);
|
|
assert_eq!(pc.assignments["red_led"].pin, 13);
|
|
assert_eq!(pc.assignments["red_led"].mode, "output");
|
|
assert_eq!(pc.assignments["limit_switch"].pin, 7);
|
|
assert_eq!(pc.buses.len(), 2);
|
|
assert_eq!(*pc.buses["spi"].user_pins.get("cs").unwrap(), 10u8);
|
|
assert!(pc.buses["i2c"].user_pins.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_without_pins_loads_empty() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("no_pins");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert!(loaded.pins.is_empty(), "Config without pins section should load with empty pins");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_pins_skip_serializing_when_empty() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("empty_pins");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
assert!(
|
|
!content.contains("[pins"),
|
|
"Empty pins should not appear in serialized config"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pins_per_board_independence() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("multi_board_pins");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: None,
|
|
});
|
|
|
|
// Different pin numbers for same friendly name on different boards
|
|
let mut uno_assigns = HashMap::new();
|
|
uno_assigns.insert("status_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
|
|
let mut mega_assigns = HashMap::new();
|
|
mega_assigns.insert("status_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
mega_assigns.insert("extra_led".to_string(), PinAssignment {
|
|
pin: 22, mode: "output".to_string(),
|
|
});
|
|
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments: uno_assigns, buses: HashMap::new(),
|
|
});
|
|
config.pins.insert("mega".to_string(), BoardPinConfig {
|
|
assignments: mega_assigns, buses: HashMap::new(),
|
|
});
|
|
|
|
config.save(tmp.path()).unwrap();
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
|
|
let uno_pins = loaded.pins.get("uno").unwrap();
|
|
let mega_pins = loaded.pins.get("mega").unwrap();
|
|
|
|
assert_eq!(uno_pins.assignments.len(), 1);
|
|
assert_eq!(mega_pins.assignments.len(), 2);
|
|
assert!(mega_pins.assignments.contains_key("extra_led"));
|
|
assert!(!uno_pins.assignments.contains_key("extra_led"));
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: save_pins preserves existing config
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_save_pins_preserves_existing_config() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("preserve_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Reload, add pins, save
|
|
let mut config = ProjectConfig::load(tmp.path()).unwrap();
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Reload and verify original fields survived
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.project.name, "preserve_test");
|
|
assert_eq!(loaded.build.default, "uno");
|
|
assert_eq!(loaded.monitor.baud, 115200);
|
|
assert!(loaded.build.extra_flags.contains(&"-Werror".to_string()));
|
|
// And pins are there
|
|
assert_eq!(loaded.pins["uno"].assignments["led"].pin, 13);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: generated pins.h content
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_generate_pins_header_content() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment, BusConfig};
|
|
use anvil::commands::pin::generate_pins_header;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("gen_test");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("red_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
assignments.insert("motor".to_string(), PinAssignment {
|
|
pin: 9, mode: "pwm".to_string(),
|
|
});
|
|
|
|
let mut spi_pins = HashMap::new();
|
|
spi_pins.insert("cs".to_string(), 10u8);
|
|
let mut buses = HashMap::new();
|
|
buses.insert("spi".to_string(), BusConfig { user_pins: spi_pins });
|
|
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses,
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
generate_pins_header(None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let header = fs::read_to_string(tmp.path().join("lib/hal/pins.h")).unwrap();
|
|
assert!(header.contains("#pragma once"), "pins.h should have pragma once");
|
|
assert!(header.contains("namespace Pins"), "pins.h should have Pins namespace");
|
|
assert!(header.contains("constexpr uint8_t RED_LED = 13;"), "pins.h should have RED_LED");
|
|
assert!(header.contains("constexpr uint8_t MOTOR = 9;"), "pins.h should have MOTOR");
|
|
assert!(header.contains("SPI_SCK"), "pins.h should have SPI bus pins");
|
|
assert!(header.contains("SPI_MOSI"), "pins.h should have SPI MOSI");
|
|
assert!(header.contains("SPI_CS = 10"), "pins.h should have user-selected CS");
|
|
assert!(header.contains("Auto-generated by Anvil"), "pins.h should have generation comment");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_pins_header_is_ascii_only() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::generate_pins_header;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("ascii_gen");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("sensor".to_string(), PinAssignment {
|
|
pin: 5, mode: "input".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
generate_pins_header(None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let header = fs::read_to_string(tmp.path().join("lib/hal/pins.h")).unwrap();
|
|
for (line_num, line) in header.lines().enumerate() {
|
|
for (col, ch) in line.chars().enumerate() {
|
|
assert!(
|
|
ch.is_ascii(),
|
|
"Non-ASCII in pins.h at {}:{}: '{}' (U+{:04X})",
|
|
line_num + 1, col + 1, ch, ch as u32
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_pins_header_creates_hal_dir() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::generate_pins_header;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("dir_gen");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Don't pre-create lib/hal -- generate should create it
|
|
assert!(!tmp.path().join("lib/hal").exists());
|
|
generate_pins_header(None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
assert!(tmp.path().join("lib/hal/pins.h").exists());
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: init-from copies assignments between boards
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_init_from_copies_assignments() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment, BusConfig};
|
|
use anvil::commands::pin::init_from;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("init_test");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: None,
|
|
});
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("red_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
assignments.insert("button".to_string(), PinAssignment {
|
|
pin: 7, mode: "input".to_string(),
|
|
});
|
|
|
|
let mut spi_pins = HashMap::new();
|
|
spi_pins.insert("cs".to_string(), 10u8);
|
|
let mut buses = HashMap::new();
|
|
buses.insert("spi".to_string(), BusConfig { user_pins: spi_pins });
|
|
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses,
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
init_from("uno", "mega", Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let mega_pins = loaded.pins.get("mega").unwrap();
|
|
assert_eq!(mega_pins.assignments.len(), 2);
|
|
assert_eq!(mega_pins.assignments["red_led"].pin, 13);
|
|
assert_eq!(mega_pins.assignments["button"].pin, 7);
|
|
assert_eq!(mega_pins.buses.len(), 1);
|
|
assert!(mega_pins.buses.contains_key("spi"));
|
|
|
|
// Source should still be intact
|
|
let uno_pins = loaded.pins.get("uno").unwrap();
|
|
assert_eq!(uno_pins.assignments.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_init_from_fails_without_target_board() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::init_from;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("init_fail");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// mega not in boards map -> should fail
|
|
let result = init_from("uno", "mega", Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "init_from should fail when target board doesn't exist");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: assignment validation
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_pin_validates_pin_exists() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("validate_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Pin 99 doesn't exist on uno
|
|
let result = assign_pin("led", "99", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject pin 99 on uno");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_validates_mode() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("mode_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Pin 4 doesn't support PWM on uno
|
|
let result = assign_pin("motor", "4", Some("pwm"), None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject PWM on non-PWM pin");
|
|
|
|
// Pin 9 does support PWM
|
|
let result = assign_pin("motor", "9", Some("pwm"), None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept PWM on PWM-capable pin");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_validates_name() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("name_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Reserved names
|
|
let result = assign_pin("spi", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject reserved name 'spi'");
|
|
|
|
// Invalid characters
|
|
let result = assign_pin("my-led", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject name with hyphens");
|
|
|
|
// Starting with number
|
|
let result = assign_pin("1led", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject name starting with number");
|
|
|
|
// Valid name
|
|
let result = assign_pin("status_led", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept valid name");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_accepts_aliases() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("alias_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// A0 should resolve to pin 14 on uno
|
|
let result = assign_pin("temp_sensor", "A0", Some("analog"), None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept A0 alias");
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert_eq!(pc.assignments["temp_sensor"].pin, 14);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: bus group assignment
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_bus_spi_with_cs() {
|
|
use anvil::commands::pin::assign_bus;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("bus_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let user_pins = vec![("cs", "10")];
|
|
let result = assign_bus("spi", &user_pins, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept SPI with CS pin");
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert!(pc.buses.contains_key("spi"));
|
|
assert_eq!(*pc.buses["spi"].user_pins.get("cs").unwrap(), 10u8);
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_bus_spi_without_cs_fails() {
|
|
use anvil::commands::pin::assign_bus;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("bus_fail");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// SPI requires CS pin
|
|
let result = assign_bus("spi", &[], None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "SPI should require CS pin");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_bus_i2c_no_user_pins() {
|
|
use anvil::commands::pin::assign_bus;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("i2c_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// I2C has no user-selectable pins
|
|
let result = assign_bus("i2c", &[], None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "I2C should work with no user pins");
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert!(pc.buses.contains_key("i2c"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_bus_unknown_fails() {
|
|
use anvil::commands::pin::assign_bus;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("unknown_bus");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_bus("can", &[], None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject unknown bus name");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: remove assignment
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_remove_pin_assignment() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::remove_assignment;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("remove_test");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("red_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
assignments.insert("green_led".to_string(), PinAssignment {
|
|
pin: 11, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
remove_assignment("red_led", None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert!(!pc.assignments.contains_key("red_led"), "red_led should be removed");
|
|
assert!(pc.assignments.contains_key("green_led"), "green_led should remain");
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_nonexistent_fails() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::remove_assignment;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("remove_fail");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = remove_assignment("nope", None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should fail when removing nonexistent assignment");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: board-specific assignments via --board flag
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_pin_to_specific_board() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("board_pin");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: None,
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Assign to mega specifically
|
|
assign_pin("led", "13", None, Some("mega"), Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert!(loaded.pins.contains_key("mega"), "Should have mega pin config");
|
|
assert!(!loaded.pins.contains_key("uno"), "Should NOT have uno pin config");
|
|
assert_eq!(loaded.pins["mega"].assignments["led"].pin, 13);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: overwrite existing assignment
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_pin_overwrites_existing() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("overwrite_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Assign red_led to pin 13
|
|
assign_pin("red_led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.pins["uno"].assignments["red_led"].pin, 13);
|
|
|
|
// Reassign red_led to pin 6
|
|
assign_pin("red_led", "6", Some("pwm"), None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.pins["uno"].assignments["red_led"].pin, 6);
|
|
assert_eq!(loaded.pins["uno"].assignments["red_led"].mode, "pwm");
|
|
assert_eq!(loaded.pins["uno"].assignments.len(), 1, "Should still be one assignment");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_multiple_pins_sequentially() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("multi_assign");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
assign_pin("red_led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
assign_pin("green_led", "11", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
assign_pin("button", "7", Some("input"), None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
assign_pin("pot", "A0", Some("analog"), None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert_eq!(pc.assignments.len(), 4);
|
|
assert_eq!(pc.assignments["red_led"].pin, 13);
|
|
assert_eq!(pc.assignments["green_led"].pin, 11);
|
|
assert_eq!(pc.assignments["button"].pin, 7);
|
|
assert_eq!(pc.assignments["button"].mode, "input");
|
|
assert_eq!(pc.assignments["pot"].pin, 14); // A0 = digital 14 on uno
|
|
assert_eq!(pc.assignments["pot"].mode, "analog");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: mode defaults and validation edge cases
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_pin_defaults_to_output() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("default_mode");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// No mode specified -> should default to "output"
|
|
assign_pin("led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.pins["uno"].assignments["led"].mode, "output");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_rejects_analog_on_digital_only() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("analog_reject");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Pin 4 on uno is digital-only, no analog capability
|
|
let result = assign_pin("sensor", "4", Some("analog"), None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject analog mode on digital-only pin");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_rejects_invalid_mode_string() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("bad_mode");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("led", "13", Some("servo"), None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject unknown mode 'servo'");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_input_pullup_mode() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("pullup_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("button", "7", Some("input_pullup"), None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept input_pullup mode");
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.pins["uno"].assignments["button"].mode, "input_pullup");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: alias resolution edge cases
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_pin_led_builtin_alias() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("builtin_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("status", "LED_BUILTIN", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept LED_BUILTIN alias");
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(loaded.pins["uno"].assignments["status"].pin, 13);
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_sda_alias() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("sda_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("temp_data", "SDA", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept SDA alias");
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
// Uno SDA = pin 18 (A4)
|
|
assert_eq!(loaded.pins["uno"].assignments["temp_data"].pin, 18);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: name validation edge cases
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_assign_pin_underscore_prefix_ok() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("underscore_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("_internal", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept name starting with underscore");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_all_uppercase_ok() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("upper_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("STATUS_LED", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Should accept all-uppercase name");
|
|
}
|
|
|
|
#[test]
|
|
fn test_assign_pin_rejects_spaces() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("space_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = assign_pin("my led", "13", None, None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should reject name with spaces");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: remove bus assignment
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_remove_bus_assignment() {
|
|
use anvil::project::config::{BoardPinConfig, BusConfig};
|
|
use anvil::commands::pin::remove_assignment;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("remove_bus");
|
|
|
|
let mut buses = HashMap::new();
|
|
let mut spi_pins = HashMap::new();
|
|
spi_pins.insert("cs".to_string(), 10u8);
|
|
buses.insert("spi".to_string(), BusConfig { user_pins: spi_pins });
|
|
buses.insert("i2c".to_string(), BusConfig { user_pins: HashMap::new() });
|
|
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments: HashMap::new(),
|
|
buses,
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
remove_assignment("spi", None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
assert!(!pc.buses.contains_key("spi"), "spi should be removed");
|
|
assert!(pc.buses.contains_key("i2c"), "i2c should remain");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: generate_pins_header edge cases
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_generate_fails_with_no_assignments() {
|
|
use anvil::commands::pin::generate_pins_header;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("empty_gen");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = generate_pins_header(None, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_err(), "Should fail when no pin assignments exist");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_header_includes_board_info_comment() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::generate_pins_header;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("comment_gen");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
generate_pins_header(None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let header = fs::read_to_string(tmp.path().join("lib/hal/pins.h")).unwrap();
|
|
assert!(header.contains("uno"), "pins.h should mention board name");
|
|
assert!(header.contains("arduino:avr:uno"), "pins.h should mention FQBN");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_header_names_are_uppercased() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::generate_pins_header;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("upper_gen");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("motor_pwm".to_string(), PinAssignment {
|
|
pin: 9, mode: "pwm".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
generate_pins_header(None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let header = fs::read_to_string(tmp.path().join("lib/hal/pins.h")).unwrap();
|
|
assert!(header.contains("MOTOR_PWM"), "pins.h should uppercase pin names");
|
|
assert!(!header.contains("motor_pwm"), "pins.h should not have lowercase pin names");
|
|
}
|
|
|
|
#[test]
|
|
fn test_generate_header_includes_stdint() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::generate_pins_header;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("stdint_gen");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
generate_pins_header(None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let header = fs::read_to_string(tmp.path().join("lib/hal/pins.h")).unwrap();
|
|
assert!(header.contains("#include <stdint.h>"), "pins.h should include stdint.h for uint8_t");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: audit smoke tests (don't crash)
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_audit_with_no_assignments_does_not_crash() {
|
|
use anvil::commands::pin::audit_pins;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("audit_empty");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Should succeed with helpful "no assignments" message, not crash
|
|
let result = audit_pins(None, false, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Audit with no assignments should not crash");
|
|
}
|
|
|
|
#[test]
|
|
fn test_audit_with_assignments_does_not_crash() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment, BusConfig};
|
|
use anvil::commands::pin::audit_pins;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("audit_full");
|
|
|
|
let mut assignments = HashMap::new();
|
|
assignments.insert("red_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
assignments.insert("button".to_string(), PinAssignment {
|
|
pin: 7, mode: "input".to_string(),
|
|
});
|
|
|
|
let mut spi_pins = HashMap::new();
|
|
spi_pins.insert("cs".to_string(), 10u8);
|
|
let mut buses = HashMap::new();
|
|
buses.insert("spi".to_string(), BusConfig { user_pins: spi_pins });
|
|
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments, buses,
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let result = audit_pins(None, false, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Audit with assignments should not crash");
|
|
|
|
// Brief mode should also work
|
|
let result = audit_pins(None, true, Some(tmp.path().to_str().unwrap()));
|
|
assert!(result.is_ok(), "Audit --brief should not crash");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: show_pin_map and show_capabilities smoke tests
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_show_pin_map_without_project() {
|
|
use anvil::commands::pin::show_pin_map;
|
|
|
|
// Should work with just a board name, no project needed
|
|
let result = show_pin_map(Some("uno"), None, Some("/nonexistent"));
|
|
assert!(result.is_ok(), "show_pin_map should work without a project");
|
|
}
|
|
|
|
#[test]
|
|
fn test_show_pin_map_with_filter() {
|
|
use anvil::commands::pin::show_pin_map;
|
|
|
|
let result = show_pin_map(Some("uno"), Some("pwm"), Some("/nonexistent"));
|
|
assert!(result.is_ok(), "show_pin_map with pwm filter should work");
|
|
}
|
|
|
|
#[test]
|
|
fn test_show_pin_map_invalid_filter() {
|
|
use anvil::commands::pin::show_pin_map;
|
|
|
|
let result = show_pin_map(Some("uno"), Some("dac"), Some("/nonexistent"));
|
|
assert!(result.is_err(), "show_pin_map with invalid capability should fail");
|
|
}
|
|
|
|
#[test]
|
|
fn test_show_capabilities_without_project() {
|
|
use anvil::commands::pin::show_capabilities;
|
|
|
|
let result = show_capabilities(Some("mega"), Some("/nonexistent"));
|
|
assert!(result.is_ok(), "show_capabilities should work without a project");
|
|
}
|
|
|
|
#[test]
|
|
fn test_show_pin_map_unknown_board() {
|
|
use anvil::commands::pin::show_pin_map;
|
|
|
|
let result = show_pin_map(Some("esp32"), None, Some("/nonexistent"));
|
|
assert!(result.is_err(), "show_pin_map should fail for unknown board");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: save_pins TOML writer preserves non-pin sections
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_save_pins_writer_preserves_boards_section() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("writer_test");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: Some(57600),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Assign a pin (this exercises save_pins internally)
|
|
assign_pin("led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
// Verify boards section survived
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert!(loaded.boards.contains_key("mega"), "mega board should survive pin save");
|
|
assert_eq!(loaded.boards["mega"].baud, Some(57600));
|
|
assert_eq!(loaded.build.default, "uno");
|
|
}
|
|
|
|
#[test]
|
|
fn test_save_pins_writer_idempotent() {
|
|
use anvil::commands::pin::assign_pin;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("idempotent");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Assign same pin twice -> file should stabilize
|
|
assign_pin("led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
let content1 = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
|
|
assign_pin("led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
let content2 = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
|
|
assert_eq!(content1, content2, "Saving same pin twice should produce identical output");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: init_from does NOT clobber existing target assignments
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_init_from_overwrites_existing_target_pins() {
|
|
use anvil::project::config::{BoardPinConfig, PinAssignment};
|
|
use anvil::commands::pin::init_from;
|
|
use std::collections::HashMap;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let mut config = ProjectConfig::new("overwrite_init");
|
|
config.boards.insert("mega".to_string(), BoardProfile {
|
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
|
baud: None,
|
|
});
|
|
|
|
// Source: uno has red_led on 13
|
|
let mut uno_assigns = HashMap::new();
|
|
uno_assigns.insert("red_led".to_string(), PinAssignment {
|
|
pin: 13, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("uno".to_string(), BoardPinConfig {
|
|
assignments: uno_assigns, buses: HashMap::new(),
|
|
});
|
|
|
|
// Target: mega already has something
|
|
let mut mega_assigns = HashMap::new();
|
|
mega_assigns.insert("old_pin".to_string(), PinAssignment {
|
|
pin: 22, mode: "output".to_string(),
|
|
});
|
|
config.pins.insert("mega".to_string(), BoardPinConfig {
|
|
assignments: mega_assigns, buses: HashMap::new(),
|
|
});
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// init_from replaces the entire target pin config
|
|
init_from("uno", "mega", Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let mega_pins = loaded.pins.get("mega").unwrap();
|
|
assert!(mega_pins.assignments.contains_key("red_led"), "Should have copied red_led");
|
|
assert!(!mega_pins.assignments.contains_key("old_pin"), "old_pin should be replaced");
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: assign_bus with user_pins round-trips through TOML
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_bus_user_pins_survive_toml_roundtrip() {
|
|
use anvil::commands::pin::assign_bus;
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("bus_roundtrip");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
let user_pins = vec![("cs", "10")];
|
|
assign_bus("spi", &user_pins, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
// Verify the raw TOML content is parseable
|
|
let content = fs::read_to_string(tmp.path().join(CONFIG_FILENAME)).unwrap();
|
|
assert!(
|
|
content.contains("[pins."),
|
|
"TOML should have pins section after bus assign"
|
|
);
|
|
|
|
// Reload and verify
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
let spi = pc.buses.get("spi").unwrap();
|
|
assert_eq!(*spi.user_pins.get("cs").unwrap(), 10u8);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Pin: mixed pins and buses in same board config
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_mixed_pins_and_buses_roundtrip() {
|
|
use anvil::commands::pin::{assign_pin, assign_bus};
|
|
|
|
let tmp = TempDir::new().unwrap();
|
|
let config = ProjectConfig::new("mixed_test");
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Assign individual pins
|
|
assign_pin("red_led", "13", None, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
assign_pin("button", "7", Some("input"), None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
// Assign bus groups
|
|
let spi_pins = vec![("cs", "10")];
|
|
assign_bus("spi", &spi_pins, None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
assign_bus("i2c", &[], None, Some(tmp.path().to_str().unwrap())).unwrap();
|
|
|
|
// Everything should survive the round-trip
|
|
let loaded = ProjectConfig::load(tmp.path()).unwrap();
|
|
let pc = loaded.pins.get("uno").unwrap();
|
|
|
|
assert_eq!(pc.assignments.len(), 2);
|
|
assert_eq!(pc.assignments["red_led"].pin, 13);
|
|
assert_eq!(pc.assignments["button"].pin, 7);
|
|
assert_eq!(pc.assignments["button"].mode, "input");
|
|
|
|
assert_eq!(pc.buses.len(), 2);
|
|
assert!(pc.buses.contains_key("spi"));
|
|
assert!(pc.buses.contains_key("i2c"));
|
|
assert_eq!(*pc.buses["spi"].user_pins.get("cs").unwrap(), 10u8);
|
|
}
|
|
|
|
// ==========================================================================
|
|
// Mock Arduino: template file content verification
|
|
// ==========================================================================
|
|
|
|
#[test]
|
|
fn test_mock_arduino_header_has_core_api() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "mock_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let header = fs::read_to_string(tmp.path().join("test/mocks/mock_arduino.h")).unwrap();
|
|
|
|
// Core Arduino constants
|
|
assert!(header.contains("#define INPUT"), "Should define INPUT");
|
|
assert!(header.contains("#define OUTPUT"), "Should define OUTPUT");
|
|
assert!(header.contains("#define HIGH"), "Should define HIGH");
|
|
assert!(header.contains("#define LOW"), "Should define LOW");
|
|
assert!(header.contains("#define LED_BUILTIN"), "Should define LED_BUILTIN");
|
|
assert!(header.contains("#define A0"), "Should define A0");
|
|
|
|
// Core Arduino functions
|
|
assert!(header.contains("void pinMode("), "Should declare pinMode");
|
|
assert!(header.contains("void digitalWrite("), "Should declare digitalWrite");
|
|
assert!(header.contains("int digitalRead("), "Should declare digitalRead");
|
|
assert!(header.contains("int analogRead("), "Should declare analogRead");
|
|
assert!(header.contains("void analogWrite("), "Should declare analogWrite");
|
|
assert!(header.contains("unsigned long millis()"), "Should declare millis");
|
|
assert!(header.contains("void delay("), "Should declare delay");
|
|
|
|
// Serial class
|
|
assert!(header.contains("class MockSerial"), "Should declare MockSerial");
|
|
assert!(header.contains("extern MockSerial Serial"), "Should declare global Serial");
|
|
|
|
// Test control API
|
|
assert!(header.contains("mock_arduino_reset()"), "Should have reset");
|
|
assert!(header.contains("mock_arduino_advance_millis("), "Should have advance_millis");
|
|
assert!(header.contains("mock_arduino_set_digital("), "Should have set_digital");
|
|
assert!(header.contains("mock_arduino_set_analog("), "Should have set_analog");
|
|
|
|
// String class
|
|
assert!(header.contains("class String"), "Should declare String class");
|
|
}
|
|
|
|
#[test]
|
|
fn test_mock_arduino_shims_exist() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "shim_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// Arduino.h shim should include mock_arduino.h
|
|
let arduino_h = fs::read_to_string(tmp.path().join("test/mocks/Arduino.h")).unwrap();
|
|
assert!(
|
|
arduino_h.contains("mock_arduino.h"),
|
|
"Arduino.h shim should redirect to mock_arduino.h"
|
|
);
|
|
|
|
// Wire.h shim should provide MockWire
|
|
let wire_h = fs::read_to_string(tmp.path().join("test/mocks/Wire.h")).unwrap();
|
|
assert!(wire_h.contains("class MockWire"), "Wire.h should declare MockWire");
|
|
assert!(wire_h.contains("extern MockWire Wire"), "Wire.h should declare global Wire");
|
|
|
|
// SPI.h shim should provide MockSPI
|
|
let spi_h = fs::read_to_string(tmp.path().join("test/mocks/SPI.h")).unwrap();
|
|
assert!(spi_h.contains("class MockSPI"), "SPI.h should declare MockSPI");
|
|
assert!(spi_h.contains("extern MockSPI SPI"), "SPI.h should declare global SPI");
|
|
assert!(spi_h.contains("SPI_MODE0"), "SPI.h should define SPI modes");
|
|
assert!(spi_h.contains("struct SPISettings"), "SPI.h should define SPISettings");
|
|
}
|
|
|
|
#[test]
|
|
fn test_mock_arduino_all_files_ascii() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "ascii_mock".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let mock_files = vec![
|
|
"test/mocks/mock_arduino.h",
|
|
"test/mocks/mock_arduino.cpp",
|
|
"test/mocks/Arduino.h",
|
|
"test/mocks/Wire.h",
|
|
"test/mocks/SPI.h",
|
|
];
|
|
|
|
for filename in &mock_files {
|
|
let content = fs::read_to_string(tmp.path().join(filename)).unwrap();
|
|
for (line_num, line) in content.lines().enumerate() {
|
|
for (col, ch) in line.chars().enumerate() {
|
|
assert!(
|
|
ch.is_ascii(),
|
|
"Non-ASCII in {} at {}:{}: '{}' (U+{:04X})",
|
|
filename, line_num + 1, col + 1, ch, ch as u32
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_cmake_links_mock_arduino() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "cmake_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".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();
|
|
|
|
// Should define mock_arduino library
|
|
assert!(cmake.contains("add_library(mock_arduino"), "Should define mock_arduino library");
|
|
assert!(cmake.contains("mock_arduino.cpp"), "Should compile mock_arduino.cpp");
|
|
|
|
// Both test targets should link mock_arduino
|
|
assert!(cmake.contains("target_link_libraries(test_unit"), "Should have test_unit target");
|
|
assert!(cmake.contains("target_link_libraries(test_system"), "Should have test_system target");
|
|
|
|
// System test target
|
|
assert!(cmake.contains("add_executable(test_system"), "Should build test_system");
|
|
assert!(cmake.contains("test_system.cpp"), "Should compile test_system.cpp");
|
|
|
|
// gtest discovery for both
|
|
assert!(cmake.contains("gtest_discover_tests(test_unit)"), "Should discover unit tests");
|
|
assert!(cmake.contains("gtest_discover_tests(test_system)"), "Should discover system tests");
|
|
}
|
|
|
|
#[test]
|
|
fn test_system_test_template_uses_simhal() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "sys_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let system_test = fs::read_to_string(tmp.path().join("test/test_system.cpp")).unwrap();
|
|
|
|
// Should include mock_arduino and sim_hal
|
|
assert!(system_test.contains("mock_arduino.h"), "Should include mock_arduino.h");
|
|
assert!(system_test.contains("sim_hal.h"), "Should include sim_hal.h");
|
|
assert!(system_test.contains("sys_test_app.h"), "Should reference project app header");
|
|
|
|
// Should use SimHal, not MockHal
|
|
assert!(system_test.contains("SimHal"), "Should use SimHal for system tests");
|
|
|
|
// Should call mock_arduino_reset
|
|
assert!(system_test.contains("mock_arduino_reset()"), "Should reset mock state in SetUp");
|
|
}
|
|
|
|
#[test]
|
|
fn test_root_test_scripts_exist_and_reference_test_dir() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "script_test".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
// test.sh
|
|
let test_sh = fs::read_to_string(tmp.path().join("test.sh")).unwrap();
|
|
assert!(test_sh.contains("TEST_DIR="), "test.sh should set TEST_DIR");
|
|
assert!(test_sh.contains("cmake"), "test.sh should invoke cmake");
|
|
assert!(test_sh.contains("ctest"), "test.sh should invoke ctest");
|
|
assert!(test_sh.contains("--unit"), "test.sh should support --unit flag");
|
|
assert!(test_sh.contains("--system"), "test.sh should support --system flag");
|
|
assert!(test_sh.contains("--clean"), "test.sh should support --clean flag");
|
|
|
|
// test.bat
|
|
let test_bat = fs::read_to_string(tmp.path().join("test.bat")).unwrap();
|
|
assert!(test_bat.contains("TEST_DIR"), "test.bat should set TEST_DIR");
|
|
assert!(test_bat.contains("cmake"), "test.bat should invoke cmake");
|
|
assert!(test_bat.contains("ctest"), "test.bat should invoke ctest");
|
|
assert!(test_bat.contains("--unit"), "test.bat should support --unit flag");
|
|
assert!(test_bat.contains("--system"), "test.bat should support --system flag");
|
|
} |