From 0abe907811b384473b8f427c1116b08efbb4d28e Mon Sep 17 00:00:00 2001 From: Eric Ratliff Date: Sat, 21 Feb 2026 13:27:40 -0600 Subject: [PATCH] Revamped tests into separate files --- tests/integration_test.rs | 4097 ------------------------------------ tests/test_config.rs | 617 ++++++ tests/test_library.rs | 618 ++++++ tests/test_mock_arduino.rs | 205 ++ tests/test_pins.rs | 1260 +++++++++++ tests/test_scripts.rs | 917 ++++++++ tests/test_templates.rs | 503 +++++ 7 files changed, 4120 insertions(+), 4097 deletions(-) delete mode 100644 tests/integration_test.rs create mode 100644 tests/test_config.rs create mode 100644 tests/test_library.rs create mode 100644 tests/test_mock_arduino.rs create mode 100644 tests/test_pins.rs create mode 100644 tests/test_scripts.rs create mode 100644 tests/test_templates.rs diff --git a/tests/integration_test.rs b/tests/integration_test.rs deleted file mode 100644 index aef692b..0000000 --- a/tests/integration_test.rs +++ /dev/null @@ -1,4097 +0,0 @@ -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, -}; -use anvil::library; -use anvil::commands; - -// ============================================================================ -// 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 "), - "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 "), "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 (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"); -} -// ========================================================================== -// 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 = 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 = 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 = 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 = 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" - ); -} \ No newline at end of file diff --git a/tests/test_config.rs b/tests/test_config.rs new file mode 100644 index 0000000..d803c59 --- /dev/null +++ b/tests/test_config.rs @@ -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" + ); +} \ No newline at end of file diff --git a/tests/test_library.rs b/tests/test_library.rs new file mode 100644 index 0000000..86fe4f7 --- /dev/null +++ b/tests/test_library.rs @@ -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 = 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 = 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 = 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 = 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" + ); +} \ No newline at end of file diff --git a/tests/test_mock_arduino.rs b/tests/test_mock_arduino.rs new file mode 100644 index 0000000..f730338 --- /dev/null +++ b/tests/test_mock_arduino.rs @@ -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"); +} \ No newline at end of file diff --git a/tests/test_pins.rs b/tests/test_pins.rs new file mode 100644 index 0000000..56b458d --- /dev/null +++ b/tests/test_pins.rs @@ -0,0 +1,1260 @@ +use anvil::project::config::{ + ProjectConfig, BoardProfile, CONFIG_FILENAME, +}; +use std::fs; +use tempfile::TempDir; + +// ========================================================================== +// 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 "), "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); +} \ No newline at end of file diff --git a/tests/test_scripts.rs b/tests/test_scripts.rs new file mode 100644 index 0000000..b1a1c31 --- /dev/null +++ b/tests/test_scripts.rs @@ -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 + ); +} \ No newline at end of file diff --git a/tests/test_templates.rs b/tests/test_templates.rs new file mode 100644 index 0000000..8858425 --- /dev/null +++ b/tests/test_templates.rs @@ -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 "), + "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()); +} \ No newline at end of file