Revamped tests into separate files

This commit is contained in:
Eric Ratliff
2026-02-21 13:27:40 -06:00
parent 706f420aaa
commit 0abe907811
7 changed files with 4120 additions and 4097 deletions

File diff suppressed because it is too large Load Diff

617
tests/test_config.rs Normal file
View File

@@ -0,0 +1,617 @@
use anvil::project::config::{
ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file,
};
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use tempfile::TempDir;
// ============================================================================
// 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()));
}
// ==========================================================================
// 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"
);
}

618
tests/test_library.rs Normal file
View File

@@ -0,0 +1,618 @@
use anvil::commands;
use anvil::library;
use anvil::project::config::{
ProjectConfig,
};
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use tempfile::TempDir;
// ==========================================================================
// Device Library: anvil add / remove / lib
// ==========================================================================
#[test]
fn test_library_registry_lists_tmp36() {
let libs = library::list_available();
assert!(!libs.is_empty(), "Should have at least one library");
let tmp36 = libs.iter().find(|l| l.name == "tmp36");
assert!(tmp36.is_some(), "TMP36 should be in the registry");
let meta = tmp36.unwrap();
assert_eq!(meta.bus, "analog");
assert_eq!(meta.pins, vec!["data"]);
assert_eq!(meta.interface, "tmp36.h");
assert_eq!(meta.mock, "tmp36_mock.h");
}
#[test]
fn test_library_find_by_name() {
assert!(library::find_library("tmp36").is_some());
assert!(library::find_library("nonexistent_sensor").is_none());
}
#[test]
fn test_library_extract_creates_driver_directory() {
let tmp = TempDir::new().unwrap();
let written = library::extract_library("tmp36", tmp.path()).unwrap();
assert!(!written.is_empty(), "Should write files");
let driver_dir = tmp.path().join("lib/drivers/tmp36");
assert!(driver_dir.exists(), "Driver directory should be created");
// All four files should exist
assert!(driver_dir.join("tmp36.h").exists(), "Interface header");
assert!(driver_dir.join("tmp36_analog.h").exists(), "Implementation");
assert!(driver_dir.join("tmp36_mock.h").exists(), "Mock");
assert!(driver_dir.join("tmp36_sim.h").exists(), "Simulation");
}
#[test]
fn test_library_extract_files_content_is_valid() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
// Interface should define TempSensor class
let interface = fs::read_to_string(driver_dir.join("tmp36.h")).unwrap();
assert!(interface.contains("class TempSensor"), "Should define TempSensor");
assert!(interface.contains("readCelsius"), "Should declare readCelsius");
assert!(interface.contains("readFahrenheit"), "Should declare readFahrenheit");
assert!(interface.contains("readRaw"), "Should declare readRaw");
// Implementation should include hal.h
let impl_h = fs::read_to_string(driver_dir.join("tmp36_analog.h")).unwrap();
assert!(impl_h.contains("hal.h"), "Implementation should use HAL");
assert!(impl_h.contains("class Tmp36Analog"), "Should define Tmp36Analog");
assert!(impl_h.contains("analogRead"), "Should use analogRead");
// Mock should have setTemperature
let mock_h = fs::read_to_string(driver_dir.join("tmp36_mock.h")).unwrap();
assert!(mock_h.contains("class Tmp36Mock"), "Should define Tmp36Mock");
assert!(mock_h.contains("setTemperature"), "Mock should have setTemperature");
// Sim should have noise
let sim_h = fs::read_to_string(driver_dir.join("tmp36_sim.h")).unwrap();
assert!(sim_h.contains("class Tmp36Sim"), "Should define Tmp36Sim");
assert!(sim_h.contains("setNoise"), "Sim should have setNoise");
}
#[test]
fn test_library_remove_cleans_up() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
assert!(library::is_installed_on_disk("tmp36", tmp.path()));
library::remove_library_files("tmp36", tmp.path()).unwrap();
assert!(!library::is_installed_on_disk("tmp36", tmp.path()));
// drivers/ dir should also be cleaned up if empty
assert!(!tmp.path().join("lib/drivers").exists());
}
#[test]
fn test_library_remove_preserves_other_drivers() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
// Fake a second driver
let other_dir = tmp.path().join("lib/drivers/bmp280");
fs::create_dir_all(&other_dir).unwrap();
fs::write(other_dir.join("bmp280.h"), "// placeholder").unwrap();
library::remove_library_files("tmp36", tmp.path()).unwrap();
// tmp36 gone, bmp280 still there
assert!(!library::is_installed_on_disk("tmp36", tmp.path()));
assert!(other_dir.exists(), "Other driver should survive");
}
#[test]
fn test_library_files_are_ascii_only() {
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
for entry in fs::read_dir(&driver_dir).unwrap() {
let entry = entry.unwrap();
let content = fs::read_to_string(entry.path()).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}",
entry.file_name().to_string_lossy(),
line_num + 1, col + 1, ch as u32
);
}
}
}
}
#[test]
fn test_config_libraries_field_roundtrips() {
let tmp = TempDir::new().unwrap();
let mut config = ProjectConfig::new("lib_test");
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
let loaded = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(loaded.libraries.len(), 1);
assert_eq!(loaded.libraries["tmp36"], "0.1.0");
}
#[test]
fn test_config_empty_libraries_not_serialized() {
let tmp = TempDir::new().unwrap();
let config = ProjectConfig::new("no_libs");
config.save(tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(
!content.contains("[libraries]"),
"Empty libraries should not appear in TOML"
);
}
#[test]
fn test_config_libraries_serialized_when_present() {
let tmp = TempDir::new().unwrap();
let mut config = ProjectConfig::new("has_libs");
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
let content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(
content.contains("[libraries]"),
"Non-empty libraries should appear in TOML"
);
assert!(content.contains("tmp36"));
}
#[test]
fn test_cmake_autodiscovers_driver_directories() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "cmake_drv".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("drivers"),
"CMakeLists should reference drivers directory"
);
assert!(
cmake.contains("GLOB DRIVER_DIRS"),
"CMakeLists should glob driver directories"
);
assert!(
cmake.contains("include_directories(${DRIVER_DIR})"),
"CMakeLists should add each driver to include path"
);
// Driver test auto-discovery
assert!(
cmake.contains("GLOB DRIVER_TEST_SOURCES"),
"CMakeLists should glob driver test files"
);
assert!(
cmake.contains("gtest_discover_tests(${TEST_NAME})"),
"CMakeLists should register driver tests with CTest"
);
}
// ==========================================================================
// Device Library: end-to-end command-level tests
// ==========================================================================
#[test]
fn test_add_library_full_flow() {
// Simulates: anvil new mocktest && anvil add tmp36
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "e2e_add".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();
ProjectConfig::new("e2e_add").save(tmp.path()).unwrap();
// Pre-check: no libraries, no drivers dir
let config_before = ProjectConfig::load(tmp.path()).unwrap();
assert!(config_before.libraries.is_empty());
assert!(!tmp.path().join("lib/drivers/tmp36").exists());
// Extract library and update config (mirrors what add_library does)
let meta = library::find_library("tmp36").unwrap();
let written = library::extract_library("tmp36", tmp.path()).unwrap();
assert_eq!(written.len(), 5, "Should write 5 files (4 headers + 1 test)");
let mut config = ProjectConfig::load(tmp.path()).unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include.clone());
}
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Post-check: files exist, config updated
assert!(tmp.path().join("lib/drivers/tmp36/tmp36.h").exists());
assert!(tmp.path().join("lib/drivers/tmp36/tmp36_analog.h").exists());
assert!(tmp.path().join("lib/drivers/tmp36/tmp36_mock.h").exists());
assert!(tmp.path().join("lib/drivers/tmp36/tmp36_sim.h").exists());
assert!(tmp.path().join("test/test_tmp36.cpp").exists(), "Test file in test/");
let config_after = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config_after.libraries["tmp36"], "0.1.0");
assert!(
config_after.build.include_dirs.contains(&driver_include),
"include_dirs should contain driver path"
);
}
#[test]
fn test_remove_library_full_flow() {
// Simulates: anvil add tmp36 && anvil remove tmp36
let tmp = TempDir::new().unwrap();
ProjectConfig::new("e2e_rm").save(tmp.path()).unwrap();
// Add
let meta = library::find_library("tmp36").unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
config.build.include_dirs.push(driver_include.clone());
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Remove
library::remove_library_files("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.retain(|d| d != &driver_include);
config.libraries.remove("tmp36");
config.save(tmp.path()).unwrap();
// Post-check: files gone, config clean
assert!(!tmp.path().join("lib/drivers/tmp36").exists());
assert!(!tmp.path().join("test/test_tmp36.cpp").exists(), "Test file should be removed");
let config_final = ProjectConfig::load(tmp.path()).unwrap();
assert!(config_final.libraries.is_empty());
assert!(
!config_final.build.include_dirs.contains(&driver_include),
"include_dirs should not contain driver path after remove"
);
}
#[test]
fn test_add_remove_readd_idempotent() {
// Simulates: anvil add tmp36 && anvil remove tmp36 && anvil add tmp36
let tmp = TempDir::new().unwrap();
ProjectConfig::new("e2e_idem").save(tmp.path()).unwrap();
let meta = library::find_library("tmp36").unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
// Add
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.push(driver_include.clone());
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Remove
library::remove_library_files("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.retain(|d| d != &driver_include);
config.libraries.remove("tmp36");
config.save(tmp.path()).unwrap();
assert!(!tmp.path().join("lib/drivers/tmp36").exists());
// Re-add
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.push(driver_include.clone());
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Everything back to normal
assert!(tmp.path().join("lib/drivers/tmp36/tmp36.h").exists());
let config_final = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config_final.libraries["tmp36"], "0.1.0");
assert!(config_final.build.include_dirs.contains(&driver_include));
// No duplicate include_dirs
let count = config_final.build.include_dirs.iter()
.filter(|d| *d == &driver_include)
.count();
assert_eq!(count, 1, "Should not duplicate include dir on re-add");
}
#[test]
fn test_library_interface_compiles_against_hal() {
// Verify the actual C++ content is structurally correct:
// tmp36_analog.h includes hal.h, tmp36_mock.h and tmp36_sim.h are standalone
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
let analog = fs::read_to_string(driver_dir.join("tmp36_analog.h")).unwrap();
assert!(analog.contains("#include \"hal.h\""), "Analog impl must include hal.h");
assert!(analog.contains("#include \"tmp36.h\""), "Analog impl must include interface");
assert!(analog.contains("Hal*"), "Analog impl must accept Hal pointer");
let mock = fs::read_to_string(driver_dir.join("tmp36_mock.h")).unwrap();
assert!(!mock.contains("hal.h"), "Mock should NOT depend on hal.h");
assert!(mock.contains("#include \"tmp36.h\""), "Mock must include interface");
let sim = fs::read_to_string(driver_dir.join("tmp36_sim.h")).unwrap();
assert!(!sim.contains("hal.h"), "Sim should NOT depend on hal.h");
assert!(sim.contains("#include \"tmp36.h\""), "Sim must include interface");
}
#[test]
fn test_library_polymorphism_contract() {
// All implementations must inherit from TempSensor
let tmp = TempDir::new().unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let driver_dir = tmp.path().join("lib/drivers/tmp36");
let interface = fs::read_to_string(driver_dir.join("tmp36.h")).unwrap();
assert!(interface.contains("class TempSensor"));
assert!(interface.contains("virtual float readCelsius()"));
assert!(interface.contains("virtual int readRaw()"));
// Each impl must extend TempSensor
for (file, class) in [
("tmp36_analog.h", "Tmp36Analog"),
("tmp36_mock.h", "Tmp36Mock"),
("tmp36_sim.h", "Tmp36Sim"),
] {
let content = fs::read_to_string(driver_dir.join(file)).unwrap();
assert!(
content.contains(&format!("class {} : public TempSensor", class)),
"{} should extend TempSensor",
file
);
assert!(
content.contains("readCelsius() override"),
"{} should override readCelsius",
file
);
assert!(
content.contains("readRaw() override"),
"{} should override readRaw",
file
);
}
}
// ==========================================================================
// Device Library: UX helpers and pin integration
// ==========================================================================
#[test]
fn test_library_meta_wiring_summary() {
let meta = library::find_library("tmp36").unwrap();
let summary = meta.wiring_summary();
assert!(summary.contains("analog"), "Should mention analog bus");
assert!(summary.contains("A0"), "Should give A0 as example pin");
}
#[test]
fn test_library_meta_pin_roles() {
let meta = library::find_library("tmp36").unwrap();
let roles = meta.pin_roles();
assert_eq!(roles.len(), 1);
assert_eq!(roles[0].0, "data");
assert_eq!(roles[0].1, "tmp36_data");
}
#[test]
fn test_library_meta_default_mode() {
let meta = library::find_library("tmp36").unwrap();
assert_eq!(meta.default_mode(), "analog");
}
#[test]
fn test_library_unassigned_pins_detects_missing() {
let meta = library::find_library("tmp36").unwrap();
let assigned: Vec<String> = vec![];
let missing = library::unassigned_pins(&meta, &assigned);
assert_eq!(missing, vec!["tmp36_data"], "Should flag tmp36_data as unassigned");
}
#[test]
fn test_library_unassigned_pins_detects_assigned() {
let meta = library::find_library("tmp36").unwrap();
let assigned = vec!["tmp36_data".to_string()];
let missing = library::unassigned_pins(&meta, &assigned);
assert!(missing.is_empty(), "Should detect tmp36_data as assigned");
}
#[test]
fn test_add_with_pin_creates_assignment() {
// Simulates: anvil new test_proj && anvil add tmp36 --pin A0
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "pin_lib".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();
// Extract library files
let meta = library::find_library("tmp36").unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
// Update config like add_library does
let mut config = ProjectConfig::load(tmp.path()).unwrap();
let driver_include = format!("lib/drivers/{}", meta.name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include);
}
config.libraries.insert(meta.name.clone(), meta.version.clone());
config.save(tmp.path()).unwrap();
// Simulate --pin A0 by calling assign_pin
let assign_name = meta.pin_assignment_name(&meta.pins[0]);
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::assign_pin(
&assign_name, "A0",
Some(meta.default_mode()),
None,
Some(&dir_str),
).unwrap();
// Verify the assignment exists
let config_after = ProjectConfig::load(tmp.path()).unwrap();
let board_pins = config_after.pins.get("uno").unwrap();
assert!(
board_pins.assignments.contains_key("tmp36_data"),
"Should have tmp36_data pin assignment"
);
let assignment = &board_pins.assignments["tmp36_data"];
assert_eq!(assignment.mode, "analog");
// Verify unassigned_pins now returns empty
let assigned: Vec<String> = board_pins.assignments.keys().cloned().collect();
let missing = library::unassigned_pins(&meta, &assigned);
assert!(missing.is_empty(), "All library pins should be assigned after --pin");
}
#[test]
fn test_audit_with_library_missing_pin() {
// anvil add tmp36 without --pin should leave pin unassigned
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "audit_lib".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();
// Add library to config but no pin assignment
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
// Check that unassigned_pins detects it
let meta = library::find_library("tmp36").unwrap();
let board_pins = config.pins.get("uno");
let assigned: Vec<String> = board_pins
.map(|bp| bp.assignments.keys().cloned().collect())
.unwrap_or_default();
let missing = library::unassigned_pins(&meta, &assigned);
assert_eq!(missing, vec!["tmp36_data"]);
// The actual audit command must not crash (this was a bug:
// audit used to early-return when no pins assigned, skipping library check)
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::audit_pins(None, false, Some(&dir_str)).unwrap();
}
#[test]
fn test_audit_with_library_pin_assigned() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "audit_ok".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();
// Add library + pin assignment
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
config.save(tmp.path()).unwrap();
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::assign_pin("tmp36_data", "A0", Some("analog"), None, Some(&dir_str)).unwrap();
// Now check data model
let config_after = ProjectConfig::load(tmp.path()).unwrap();
let board_pins = config_after.pins.get("uno").unwrap();
let assigned: Vec<String> = board_pins.assignments.keys().cloned().collect();
let meta = library::find_library("tmp36").unwrap();
let missing = library::unassigned_pins(&meta, &assigned);
assert!(missing.is_empty(), "Pin should be satisfied after assignment");
// And the audit command itself should work with the pin assigned
commands::pin::audit_pins(None, false, Some(&dir_str)).unwrap();
}
#[test]
fn test_audit_no_pins_no_libraries_does_not_crash() {
// Baseline: no pins, no libraries -- audit should still work
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "audit_empty".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 dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::audit_pins(None, false, Some(&dir_str)).unwrap();
}
#[test]
fn test_add_remove_pin_assignment_survives() {
// When we remove a library, the pin assignment should still exist
// (the user might want to reassign it to a different library)
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "pin_survive".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();
// Add library and assign pin
let meta = library::find_library("tmp36").unwrap();
library::extract_library("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.libraries.insert("tmp36".to_string(), "0.1.0".to_string());
let driver_include = format!("lib/drivers/{}", meta.name);
config.build.include_dirs.push(driver_include.clone());
config.save(tmp.path()).unwrap();
let dir_str = tmp.path().to_string_lossy().to_string();
commands::pin::assign_pin("tmp36_data", "A0", Some("analog"), None, Some(&dir_str)).unwrap();
// Remove library
library::remove_library_files("tmp36", tmp.path()).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.build.include_dirs.retain(|d| d != &driver_include);
config.libraries.remove("tmp36");
config.save(tmp.path()).unwrap();
// Pin assignment should still be there
let config_final = ProjectConfig::load(tmp.path()).unwrap();
let board_pins = config_final.pins.get("uno").unwrap();
assert!(
board_pins.assignments.contains_key("tmp36_data"),
"Pin assignment should survive library removal"
);
}

205
tests/test_mock_arduino.rs Normal file
View File

@@ -0,0 +1,205 @@
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use tempfile::TempDir;
// ==========================================================================
// 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 (declarations use aligned whitespace)
assert!(header.contains("pinMode("), "Should declare pinMode");
assert!(header.contains("digitalWrite("), "Should declare digitalWrite");
assert!(header.contains("digitalRead("), "Should declare digitalRead");
assert!(header.contains("analogRead("), "Should declare analogRead");
assert!(header.contains("analogWrite("), "Should declare analogWrite");
assert!(header.contains("millis()"), "Should declare millis");
assert!(header.contains("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");
}

1260
tests/test_pins.rs Normal file

File diff suppressed because it is too large Load Diff

917
tests/test_scripts.rs Normal file
View File

@@ -0,0 +1,917 @@
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use tempfile::TempDir;
// ============================================================================
// 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
);
}
}
// ==========================================================================
// 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
);
}

503
tests/test_templates.rs Normal file
View File

@@ -0,0 +1,503 @@
use anvil::project::config::{
ProjectConfig,
};
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use std::path::Path;
use tempfile::TempDir;
// ============================================================================
// 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");
}
// ============================================================================
// 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());
}