use tempfile::TempDir; use std::fs; use std::path::Path; use anvil::templates::{TemplateManager, TemplateContext}; use anvil::project::config::{ ProjectConfig, BoardProfile, CONFIG_FILENAME, set_default_in_file, }; // ============================================================================ // Template extraction tests // ============================================================================ #[test] fn test_basic_template_extracts_all_expected_files() { let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "test_proj".to_string(), anvil_version: "1.0.0".to_string(), board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); assert!(count >= 16, "Expected at least 16 files, got {}", count); } #[test] fn test_template_creates_sketch_directory() { let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "blink".to_string(), anvil_version: "1.0.0".to_string(), board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); let sketch_dir = tmp.path().join("blink"); assert!(sketch_dir.is_dir(), "Sketch directory should exist"); let ino_file = sketch_dir.join("blink.ino"); assert!(ino_file.exists(), "Sketch .ino file should exist"); // Verify the .ino content has correct includes let content = fs::read_to_string(&ino_file).unwrap(); assert!( content.contains("blink_app.h"), ".ino should include project-specific app header" ); assert!( content.contains("hal_arduino.h"), ".ino should include hal_arduino.h" ); } #[test] fn test_template_creates_hal_files() { let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "sensor".to_string(), anvil_version: "1.0.0".to_string(), board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); assert!( tmp.path().join("lib/hal/hal.h").exists(), "hal.h should exist" ); assert!( tmp.path().join("lib/hal/hal_arduino.h").exists(), "hal_arduino.h should exist" ); // Verify hal.h defines the abstract Hal class let hal_content = fs::read_to_string(tmp.path().join("lib/hal/hal.h")).unwrap(); assert!( hal_content.contains("class Hal"), "hal.h should define class Hal" ); assert!( hal_content.contains("virtual void pinMode"), "hal.h should declare pinMode" ); } #[test] fn test_template_creates_app_header() { let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "my_sensor".to_string(), anvil_version: "1.0.0".to_string(), board_name: "uno".to_string(), fqbn: "arduino:avr:uno".to_string(), baud: 115200, }; TemplateManager::extract("basic", tmp.path(), &ctx).unwrap(); let app_path = tmp.path().join("lib/app/my_sensor_app.h"); assert!(app_path.exists(), "App header should exist with project name"); let content = fs::read_to_string(&app_path).unwrap(); assert!( content.contains("#include "), "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/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/run_tests.sh", "test/run_tests.bat", "test/mocks/mock_hal.h", "test/mocks/sim_hal.h", ]; for f in &expected_files { let p = tmp.path().join(f); assert!( p.exists(), "Expected file missing: {} (checked {})", f, p.display() ); } } #[test] fn test_no_unicode_in_template_output() { // Eric's rule: only ASCII characters let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "ascii_test".to_string(), anvil_version: "1.0.0".to_string(), 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", "_detect_port.ps1", "_monitor_filter.ps1", "test/run_tests.sh", "test/run_tests.bat", ]; for f in &refreshable { let a = fs::read(tmp.path().join(f)).unwrap(); let b = fs::read(reference.path().join(f)).unwrap(); assert_eq!(a, b, "Freshly extracted {} should match template", f); } } #[test] fn test_refresh_detects_modified_script() { // Tampering with a script should cause a byte mismatch let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "mod_proj".to_string(), anvil_version: "1.0.0".to_string(), 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 config must never be refreshable. let never_refreshable = vec![ ".anvil.toml", ".anvil.local", ".gitignore", ".editorconfig", ".clang-format", "README.md", "test/CMakeLists.txt", "test/test_unit.cpp", "test/mocks/mock_hal.h", "test/mocks/sim_hal.h", ]; let refreshable = vec![ "build.sh", "build.bat", "upload.sh", "upload.bat", "monitor.sh", "monitor.bat", "_detect_port.ps1", "_monitor_filter.ps1", "test/run_tests.sh", "test/run_tests.bat", ]; for uf in &never_refreshable { assert!( !refreshable.contains(uf), "{} must never be in the refreshable files list", uf ); } } // ========================================================================== // .anvil.local VID:PID in scripts // ========================================================================== #[test] fn test_scripts_read_vid_pid_from_anvil_local() { // upload and monitor scripts should parse vid_pid from .anvil.local let tmp = TempDir::new().unwrap(); let ctx = TemplateContext { project_name: "vidpid_test".to_string(), anvil_version: "1.0.0".to_string(), 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")); }