Trying to fix tests in project, not done yet

This commit is contained in:
Eric Ratliff
2026-02-21 20:52:48 -06:00
parent 0abe907811
commit 8a72098443
10 changed files with 1876 additions and 147 deletions

View File

@@ -5,6 +5,41 @@ use std::path::PathBuf;
use crate::library;
use crate::project::config::ProjectConfig;
/// Install a library quietly -- extract files and update config only.
/// Used by the template orchestrator during `anvil new`.
/// Returns the list of written file paths relative to project root.
pub fn install_library(
name: &str,
project_dir: &std::path::Path,
) -> Result<Vec<String>> {
let meta = library::find_library(name).ok_or_else(|| {
anyhow::anyhow!("Unknown library: '{}'", name)
})?;
let project_root = ProjectConfig::find_project_root(project_dir)?;
let mut config = ProjectConfig::load(&project_root)?;
// Skip if already installed
if config.libraries.contains_key(name) {
return Ok(vec![]);
}
// Extract files
let written = library::extract_library(name, &project_root)?;
// Add to include_dirs if not present
let driver_include = format!("lib/drivers/{}", name);
if !config.build.include_dirs.contains(&driver_include) {
config.build.include_dirs.push(driver_include);
}
// Track in config
config.libraries.insert(name.to_string(), meta.version.clone());
config.save(&project_root)?;
Ok(written)
}
/// Add a library to the current project.
pub fn add_library(name: &str, pin: Option<&str>, project_dir: Option<&str>) -> Result<()> {
let meta = library::find_library(name)

View File

@@ -2,6 +2,7 @@ use anyhow::{Result, bail};
use colored::*;
use std::path::PathBuf;
use crate::board::pinmap;
use crate::board::presets::{self, BoardPreset};
use crate::templates::{TemplateManager, TemplateContext};
use crate::version::ANVIL_VERSION;
@@ -18,12 +19,43 @@ pub fn list_templates() -> Result<()> {
marker.bright_cyan()
);
println!(" {}", info.description);
if !info.libraries.is_empty() {
// Show what drivers are included and their wiring needs
let mut wiring_parts: Vec<String> = Vec::new();
for lib_name in &info.libraries {
if let Some(meta) = crate::library::find_library(lib_name) {
wiring_parts.push(format!(
"{} ({})",
meta.name, meta.wiring_summary()
));
}
}
println!(
" Includes: {}",
wiring_parts.join(", ").bright_yellow()
);
}
// Show the create command for non-default templates
if !info.is_default {
println!(
" Create: {}",
format!(
"anvil new <project-name> --template {}",
info.name
)
.bright_cyan()
);
}
println!();
}
println!("{}", "Usage:".bright_yellow().bold());
println!(" anvil new <project-name>");
println!(" anvil new <project-name> --template basic");
println!(
" anvil new <project-name> --template <name> --board <board>"
);
println!();
Ok(())
@@ -58,12 +90,10 @@ pub fn list_boards() -> Result<()> {
println!();
println!(
" {}",
"For boards not listed here, create a project and then:".bright_black()
);
println!(
" {}",
" anvil board --listall".bright_black()
"For boards not listed here, create a project and then:"
.bright_black()
);
println!(" {}", " anvil board --listall".bright_black());
println!();
Ok(())
@@ -90,7 +120,9 @@ pub fn create_project(
if !TemplateManager::template_exists(template_name) {
println!(
"{}",
format!("Template '{}' not found.", template_name).red().bold()
format!("Template '{}' not found.", template_name)
.red()
.bold()
);
println!();
list_templates()?;
@@ -99,8 +131,7 @@ pub fn create_project(
// Resolve board preset
let preset: &BoardPreset = match board {
Some(b) => {
match presets::find_preset(b) {
Some(b) => match presets::find_preset(b) {
Some(p) => p,
None => {
println!(
@@ -111,28 +142,36 @@ pub fn create_project(
list_boards()?;
bail!("Invalid board preset");
}
}
}
},
None => presets::find_preset(presets::DEFAULT_PRESET).unwrap(),
};
// Check board compatibility for composed templates (warn, never block)
if let Some(meta) = TemplateManager::composed_meta(template_name) {
check_board_compatibility(preset, &meta.board_capabilities);
}
println!(
"{}",
format!("Creating Arduino project: {}", name)
.bright_green()
.bold()
);
println!("{}", format!("Template: {}", template_name).bright_cyan());
println!(
"{}",
format!("Board: {} ({})", preset.name, preset.description).bright_cyan()
format!("Template: {}", template_name).bright_cyan()
);
println!(
"{}",
format!("Board: {} ({})", preset.name, preset.description)
.bright_cyan()
);
println!();
// Create project directory
std::fs::create_dir_all(&project_path)?;
// Extract template
// Extract template (basic scaffold + overlay for composed templates)
println!("{}", "Extracting template files...".bright_yellow());
let context = TemplateContext {
project_name: name.to_string(),
@@ -142,9 +181,74 @@ pub fn create_project(
baud: preset.baud,
};
let file_count = TemplateManager::extract(template_name, &project_path, &context)?;
let file_count =
TemplateManager::extract(template_name, &project_path, &context)?;
println!("{} Extracted {} files", "ok".green(), file_count);
// For composed templates: install libraries and assign pins
if let Some(meta) = TemplateManager::composed_meta(template_name) {
// Install required libraries
for lib_name in &meta.libraries {
println!();
println!(
"{}",
format!("Installing library: {}...", lib_name)
.bright_yellow()
);
let written =
crate::commands::lib::install_library(lib_name, &project_path)?;
for f in &written {
println!(" {} {}", "+".bright_green(), f.bright_white());
}
println!("{} {} installed", "ok".green(), lib_name);
}
// Assign default pins for this board
let pin_defaults = meta.pins_for_board(preset.name);
if !pin_defaults.is_empty() {
println!();
println!(
"{}",
"Assigning default pin mappings...".bright_yellow()
);
for pin_def in &pin_defaults {
match crate::commands::pin::install_pin_assignment(
&pin_def.name,
&pin_def.pin,
&pin_def.mode,
preset.name,
&project_path,
) {
Ok(()) => {
println!(
" {} {} -> {} [{}]",
"ok".green(),
pin_def.name.bold(),
pin_def.pin,
pin_def.mode
);
}
Err(e) => {
println!(
" {} {}: {}",
"!!".red(),
pin_def.name,
e
);
println!(
" Assign manually: {}",
format!(
"anvil pin --assign {} {} --mode {}",
pin_def.name, pin_def.pin, pin_def.mode
)
.bright_cyan()
);
}
}
}
}
}
// Make shell scripts executable on Unix
#[cfg(unix)]
{
@@ -174,11 +278,52 @@ pub fn create_project(
);
println!();
print_next_steps(name);
print_next_steps(name, template_name);
Ok(())
}
/// Check if a board supports the required capabilities and warn if not.
fn check_board_compatibility(
preset: &BoardPreset,
required: &[String],
) {
if required.is_empty() {
return;
}
let pm = pinmap::find_pinmap(preset.name);
if pm.is_none() {
// No pinmap data for this board -- can't check
return;
}
let pm = pm.unwrap();
let caps = pinmap::board_capabilities(pm);
for req in required {
if !caps.contains(&req.as_str()) {
println!(
"{}",
format!(
"note: This template needs '{}' pins, which {} \
may not support.",
req, preset.name
)
.bright_yellow()
);
println!(
" {}",
"Your tests will still work (mocks don't need hardware)."
.bright_black()
);
println!(
" {}",
"You may need to adjust pin assignments for your board."
.bright_black()
);
println!();
}
}
}
fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("Project name cannot be empty");
@@ -266,13 +411,47 @@ fn make_executable(project_dir: &PathBuf) {
}
}
fn print_next_steps(project_name: &str) {
fn print_next_steps(project_name: &str, template_name: &str) {
println!("{}", "Next steps:".bright_yellow().bold());
println!(
" 1. {}",
format!("cd {}", project_name).bright_cyan()
);
// For composed templates, wiring is step 2
if template_name != "basic" {
println!(
" 2. Review wiring: {}",
"anvil pin --audit".bright_cyan()
);
if cfg!(target_os = "windows") {
println!(
" 3. Run host tests: {}",
"test\\run_tests.bat --clean".bright_cyan()
);
println!(
" 4. Compile: {}",
"build.bat".bright_cyan()
);
println!(
" 5. Upload: {}",
"upload.bat --monitor".bright_cyan()
);
} else {
println!(
" 3. Run host tests: {}",
"./test/run_tests.sh --clean".bright_cyan()
);
println!(
" 4. Compile: {}",
"./build.sh".bright_cyan()
);
println!(
" 5. Upload: {}",
"./upload.sh --monitor".bright_cyan()
);
}
} else {
if cfg!(target_os = "windows") {
println!(
" 2. Compile: {}",
@@ -324,10 +503,12 @@ fn print_next_steps(project_name: &str) {
println!();
println!(
" {}",
"On Windows: build.bat, upload.bat, monitor.bat, test\\run_tests.bat"
"On Windows: build.bat, upload.bat, monitor.bat, \
test\\run_tests.bat"
.bright_black()
);
}
}
println!(
" {}",

View File

@@ -237,6 +237,43 @@ pub fn assign_pin(
Ok(())
}
/// Assign a pin quietly -- validate, write config, no printing.
/// Used by the template orchestrator during `anvil new`.
pub fn install_pin_assignment(
name: &str,
pin_str: &str,
mode: &str,
board_name: &str,
project_dir: &std::path::Path,
) -> Result<()> {
let project_root = ProjectConfig::find_project_root(project_dir)?;
let pinmap = require_pinmap(board_name)?;
let pin_num = pinmap::resolve_alias(pinmap, pin_str).ok_or_else(|| {
anyhow::anyhow!(
"Pin '{}' not found on board '{}'",
pin_str,
board_name
)
})?;
if !ALL_MODES.contains(&mode) {
bail!("Unknown pin mode: '{}'", mode);
}
let pin_info = pinmap::get_pin(pinmap, pin_num).unwrap();
validate_mode_for_pin(pin_info, mode)?;
validate_pin_name(name)?;
let assignment = PinAssignment {
pin: pin_num,
mode: mode.to_string(),
};
write_pin_assignment(&project_root, board_name, name, &assignment)?;
Ok(())
}
// =========================================================================
// Assign a bus group
// =========================================================================

View File

@@ -1,12 +1,17 @@
use include_dir::{include_dir, Dir};
use std::path::Path;
use std::fs;
use anyhow::{Result, bail, Context};
use std::collections::HashMap;
use anyhow::{Result, Context};
use serde::Deserialize;
use crate::version::ANVIL_VERSION;
// Embedded template directories
static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic");
static WEATHER_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/weather");
/// Context variables available in .tmpl files via {{VAR}} substitution.
pub struct TemplateContext {
pub project_name: String,
pub anvil_version: String,
@@ -15,87 +20,282 @@ pub struct TemplateContext {
pub baud: u32,
}
/// Summary info for listing templates.
pub struct TemplateInfo {
pub name: String,
pub description: String,
pub is_default: bool,
pub libraries: Vec<String>,
pub board_capabilities: Vec<String>,
}
/// Metadata extracted from a composed template's template.toml.
/// Used by the `new` command to install libraries and assign pins.
pub struct ComposedMeta {
pub base: String,
pub libraries: Vec<String>,
pub board_capabilities: Vec<String>,
pub pins: HashMap<String, Vec<PinDefault>>,
}
/// A default pin assignment from a template.
pub struct PinDefault {
pub name: String,
pub pin: String,
pub mode: String,
}
impl ComposedMeta {
/// Get pin defaults for a given board, falling back to "default".
pub fn pins_for_board(&self, board: &str) -> Vec<&PinDefault> {
if let Some(pins) = self.pins.get(board) {
return pins.iter().collect();
}
if let Some(pins) = self.pins.get("default") {
return pins.iter().collect();
}
vec![]
}
}
// =========================================================================
// template.toml parsing (serde)
// =========================================================================
#[derive(Deserialize)]
struct TemplateToml {
template: TemplateHeader,
requires: Option<TemplateRequires>,
pins: Option<HashMap<String, HashMap<String, PinDef>>>,
}
#[derive(Deserialize)]
struct TemplateHeader {
#[allow(dead_code)]
name: String,
base: String,
description: String,
}
#[derive(Deserialize)]
struct TemplateRequires {
libraries: Option<Vec<String>>,
board_capabilities: Option<Vec<String>>,
}
#[derive(Deserialize)]
struct PinDef {
pin: String,
mode: String,
}
/// Parse template.toml from an embedded directory.
fn parse_template_toml(dir: &Dir<'_>) -> Option<TemplateToml> {
let file = dir.files().find(|f| {
f.path()
.file_name()
.map(|n| n == "template.toml")
.unwrap_or(false)
})?;
let content = std::str::from_utf8(file.contents()).ok()?;
toml::from_str(content).ok()
}
/// Look up the embedded Dir for a template name.
fn template_dir(name: &str) -> Option<&'static Dir<'static>> {
match name {
"basic" => Some(&BASIC_TEMPLATE),
"weather" => Some(&WEATHER_TEMPLATE),
_ => None,
}
}
// All composed templates (everything except "basic").
fn composed_template_entries() -> Vec<(&'static str, &'static Dir<'static>)> {
vec![("weather", &WEATHER_TEMPLATE)]
}
// =========================================================================
// TemplateManager -- public API
// =========================================================================
pub struct TemplateManager;
impl TemplateManager {
/// Check if a template name is known.
pub fn template_exists(name: &str) -> bool {
matches!(name, "basic")
template_dir(name).is_some()
}
/// List all available templates with metadata.
pub fn list_templates() -> Vec<TemplateInfo> {
vec![
TemplateInfo {
let mut templates = vec![TemplateInfo {
name: "basic".to_string(),
description: "Arduino project with HAL abstraction, mocks, and test infrastructure".to_string(),
description: "Arduino project with HAL abstraction, mocks, \
and test infrastructure"
.to_string(),
is_default: true,
},
]
libraries: vec![],
board_capabilities: vec![],
}];
// Composed templates: parse their template.toml
for (name, dir) in composed_template_entries() {
if let Some(toml) = parse_template_toml(dir) {
templates.push(TemplateInfo {
name: name.to_string(),
description: toml.template.description,
is_default: false,
libraries: toml
.requires
.as_ref()
.and_then(|r| r.libraries.clone())
.unwrap_or_default(),
board_capabilities: toml
.requires
.as_ref()
.and_then(|r| r.board_capabilities.clone())
.unwrap_or_default(),
});
}
}
/// Extract a template into the output directory, applying variable
/// substitution and filename transformations.
templates
}
/// Extract a template into a project directory.
///
/// For base templates (basic): extracts files directly.
/// For composed templates (weather): extracts base first, then overlays.
pub fn extract(
template_name: &str,
output_dir: &Path,
context: &TemplateContext,
) -> Result<usize> {
let template_dir = match template_name {
"basic" => &BASIC_TEMPLATE,
_ => bail!("Unknown template: {}", template_name),
};
let dir = template_dir(template_name)
.ok_or_else(|| anyhow::anyhow!("Unknown template: {}", template_name))?;
// Check if this is a composed template
if let Some(toml) = parse_template_toml(dir) {
// Extract base first
let base_dir = template_dir(&toml.template.base).ok_or_else(|| {
anyhow::anyhow!(
"Template '{}' requires unknown base '{}'",
template_name,
toml.template.base
)
})?;
let mut count =
extract_dir_filtered(base_dir, output_dir, context, &[])?;
// Overlay template-specific files (skip template.toml)
count += extract_dir_filtered(
dir,
output_dir,
context,
&["template.toml"],
)?;
let count = extract_dir(template_dir, output_dir, "", context)?;
Ok(count)
} else {
// Base template -- extract directly
extract_dir_filtered(dir, output_dir, context, &[])
}
}
/// Get composed metadata for a template (libraries, pins, capabilities).
/// Returns None for base templates like "basic".
pub fn composed_meta(template_name: &str) -> Option<ComposedMeta> {
let dir = template_dir(template_name)?;
let toml = parse_template_toml(dir)?;
let libraries = toml
.requires
.as_ref()
.and_then(|r| r.libraries.clone())
.unwrap_or_default();
let board_capabilities = toml
.requires
.as_ref()
.and_then(|r| r.board_capabilities.clone())
.unwrap_or_default();
let mut pins: HashMap<String, Vec<PinDefault>> = HashMap::new();
if let Some(pin_map) = toml.pins {
for (board, assignments) in pin_map {
let mut defs: Vec<PinDefault> = assignments
.into_iter()
.map(|(name, def)| PinDefault {
name,
pin: def.pin,
mode: def.mode,
})
.collect();
// Sort for deterministic output
defs.sort_by(|a, b| a.name.cmp(&b.name));
pins.insert(board, defs);
}
}
Some(ComposedMeta {
base: toml.template.base,
libraries,
board_capabilities,
pins,
})
}
}
pub struct TemplateInfo {
pub name: String,
pub description: String,
pub is_default: bool,
}
// =========================================================================
// File extraction
// =========================================================================
/// Recursively extract a directory from the embedded template.
fn extract_dir(
/// Recursively extract files from an embedded directory, applying variable
/// substitution to .tmpl files and path transformations.
fn extract_dir_filtered(
source: &Dir<'_>,
output_base: &Path,
relative_prefix: &str,
context: &TemplateContext,
skip_filenames: &[&str],
) -> Result<usize> {
let mut count = 0;
for file in source.files() {
let file_path = file.path();
let file_name = file_path.to_string_lossy().to_string();
let file_name = file_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
// Build the output path with transformations
let output_rel = transform_path(&file_name, &context.project_name);
let output_path = output_base.join(&output_rel);
// Create parent directories
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.context(format!("Failed to create directory: {}", parent.display()))?;
// Skip files in the skip list
if skip_filenames.iter().any(|&s| s == file_name) {
continue;
}
let full_path = file_path.to_string_lossy().to_string();
let output_rel = transform_path(&full_path, &context.project_name);
let output_path = output_base.join(&output_rel);
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).context(format!(
"Failed to create directory: {}",
parent.display()
))?;
}
// Read file contents
let contents = file.contents();
// Check if this is a template file (.tmpl suffix)
if output_rel.ends_with(".tmpl") {
// Variable substitution
let text = std::str::from_utf8(contents)
.context("Template file must be UTF-8")?;
let processed = substitute_variables(text, context);
// Remove .tmpl extension
let final_path_str = output_rel.trim_end_matches(".tmpl");
let final_path = output_base.join(final_path_str);
if let Some(parent) = final_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&final_path, processed)?;
count += 1;
} else {
@@ -104,9 +304,9 @@ fn extract_dir(
}
}
// Recurse into subdirectories
for dir in source.dirs() {
count += extract_dir(dir, output_base, relative_prefix, context)?;
count +=
extract_dir_filtered(dir, output_base, context, skip_filenames)?;
}
Ok(count)
@@ -117,12 +317,8 @@ fn extract_dir(
/// - `__name__` -> project name
fn transform_path(path: &str, project_name: &str) -> String {
let mut result = path.to_string();
// Replace __name__ with project name in all path components
result = result.replace("__name__", project_name);
// Handle _dot_ prefix for hidden files.
// Split into components and transform each.
let parts: Vec<&str> = result.split('/').collect();
let transformed: Vec<String> = parts
.iter()
@@ -148,6 +344,10 @@ fn substitute_variables(text: &str, context: &TemplateContext) -> String {
.replace("{{BAUD}}", &context.baud.to_string())
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
use super::*;
@@ -182,20 +382,53 @@ mod tests {
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
baud: 9600,
};
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, FQBN: {{FQBN}}, Baud: {{BAUD}}";
let input = "Name: {{PROJECT_NAME}}, Board: {{BOARD_NAME}}, \
FQBN: {{FQBN}}, Baud: {{BAUD}}";
let output = substitute_variables(input, &ctx);
assert_eq!(
output,
"Name: my_project, Board: mega, FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
"Name: my_project, Board: mega, \
FQBN: arduino:avr:mega:cpu=atmega2560, Baud: 9600"
);
}
#[test]
fn test_template_exists() {
assert!(TemplateManager::template_exists("basic"));
assert!(TemplateManager::template_exists("weather"));
assert!(!TemplateManager::template_exists("nonexistent"));
}
#[test]
fn test_list_templates_includes_both() {
let templates = TemplateManager::list_templates();
assert!(templates.iter().any(|t| t.name == "basic"));
assert!(templates.iter().any(|t| t.name == "weather"));
}
#[test]
fn test_basic_is_default() {
let templates = TemplateManager::list_templates();
let basic = templates.iter().find(|t| t.name == "basic").unwrap();
assert!(basic.is_default);
}
#[test]
fn test_weather_requires_tmp36() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(weather.libraries.contains(&"tmp36".to_string()));
}
#[test]
fn test_weather_requires_analog() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(
weather.board_capabilities.contains(&"analog".to_string())
);
}
#[test]
fn test_extract_basic_template() {
let tmp = TempDir::new().unwrap();
@@ -207,26 +440,15 @@ mod tests {
baud: 115200,
};
let count = TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
let count =
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
assert!(count > 0, "Should extract at least one file");
// Verify key files exist
assert!(tmp.path().join(".anvil.toml").exists());
assert!(
tmp.path().join(".anvil.toml").exists(),
".anvil.toml should be created"
);
assert!(
tmp.path().join("test_proj").join("test_proj.ino").exists(),
"Sketch .ino should be created"
);
assert!(
tmp.path().join("lib").join("hal").join("hal.h").exists(),
"HAL header should be created"
);
assert!(
tmp.path().join(".gitignore").exists(),
".gitignore should be created"
tmp.path().join("test_proj").join("test_proj.ino").exists()
);
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
assert!(tmp.path().join(".gitignore").exists());
}
#[test]
@@ -241,12 +463,105 @@ mod tests {
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
let config_content =
fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(config_content.contains("my_sensor"));
}
// Read the generated .anvil.toml and check for project name
let config_content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
#[test]
fn test_weather_composed_meta() {
let meta = TemplateManager::composed_meta("weather").unwrap();
assert_eq!(meta.base, "basic");
assert!(meta.libraries.contains(&"tmp36".to_string()));
assert!(!meta.pins.is_empty());
}
#[test]
fn test_weather_pins_for_board_uno() {
let meta = TemplateManager::composed_meta("weather").unwrap();
let pins = meta.pins_for_board("uno");
assert!(!pins.is_empty());
assert!(pins.iter().any(|p| p.name == "tmp36_data"));
}
#[test]
fn test_weather_pins_for_board_fallback() {
let meta = TemplateManager::composed_meta("weather").unwrap();
// Board "micro" is not in the template, falls back to "default"
let pins = meta.pins_for_board("micro");
assert!(!pins.is_empty());
}
#[test]
fn test_basic_has_no_composed_meta() {
assert!(TemplateManager::composed_meta("basic").is_none());
}
#[test]
fn test_extract_weather_overlays_app() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "wx".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
// Should have basic scaffold
assert!(tmp.path().join(".anvil.toml").exists());
assert!(tmp.path().join("build.sh").exists());
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
// Should have weather-specific app
let app_path = tmp.path().join("lib").join("app").join("wx_app.h");
assert!(app_path.exists(), "Weather app.h should exist");
let app_content = fs::read_to_string(&app_path).unwrap();
assert!(
config_content.contains("my_sensor"),
".anvil.toml should contain project name"
app_content.contains("WeatherApp"),
"Should contain WeatherApp class"
);
assert!(
app_content.contains("TempSensor"),
"Should reference TempSensor"
);
// Should have weather-specific sketch
let ino_path = tmp.path().join("wx").join("wx.ino");
assert!(ino_path.exists());
let ino_content = fs::read_to_string(&ino_path).unwrap();
assert!(ino_content.contains("Tmp36Analog"));
assert!(ino_content.contains("WeatherApp"));
// Should have weather-specific tests
let unit_test = tmp.path().join("test").join("test_unit.cpp");
assert!(unit_test.exists());
let unit_content = fs::read_to_string(&unit_test).unwrap();
assert!(unit_content.contains("Tmp36Mock"));
let sys_test = tmp.path().join("test").join("test_system.cpp");
assert!(sys_test.exists());
let sys_content = fs::read_to_string(&sys_test).unwrap();
assert!(sys_content.contains("Tmp36Sim"));
}
#[test]
fn test_weather_no_template_toml_in_output() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "wx".to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
assert!(
!tmp.path().join("template.toml").exists(),
"template.toml should NOT appear in output"
);
}
}

View File

@@ -0,0 +1,35 @@
/*
* {{PROJECT_NAME}}.ino -- TMP36 weather station
*
* Reads temperature from a TMP36 sensor every 2 seconds
* and prints to Serial:
*
* Temperature: 23.5 C (74.3 F)
*
* All logic lives in lib/app/{{PROJECT_NAME}}_app.h which depends
* on the HAL and TempSensor interfaces, making it fully testable
* on the host without hardware.
*
* Wiring (TMP36, flat side facing you):
* Pin 1 (left) -> 5V
* Pin 2 (middle) -> A0 (analog input)
* Pin 3 (right) -> GND
*
* Serial: 115200 baud
*/
#include <hal_arduino.h>
#include <{{PROJECT_NAME}}_app.h>
#include <tmp36_analog.h>
static ArduinoHal hw;
static Tmp36Analog sensor(&hw, A0);
static WeatherApp app(&hw, &sensor);
void setup() {
app.begin();
}
void loop() {
app.update();
}

View File

@@ -0,0 +1,100 @@
#ifndef APP_H
#define APP_H
#include <hal.h>
#include "tmp36.h"
#include <stdio.h>
/*
* WeatherApp -- Reads a TMP36 temperature sensor and reports to Serial.
*
* Every READ_INTERVAL_MS (2 seconds), reads the temperature and prints
* a formatted line like:
*
* Temperature: 23.5 C (74.3 F)
*
* The sensor is injected through the TempSensor interface, so this
* class works with real hardware, mocks, or simulations.
*
* Wiring (TMP36):
* Pin 1 (left, flat side facing you) -> 5V
* Pin 2 (middle) -> A0 (analog input)
* Pin 3 (right) -> GND
*/
class WeatherApp {
public:
static constexpr unsigned long READ_INTERVAL_MS = 2000;
WeatherApp(Hal* hal, TempSensor* sensor)
: hal_(hal)
, sensor_(sensor)
, last_read_ms_(0)
, last_celsius_(0.0f)
, last_fahrenheit_(0.0f)
, read_count_(0)
{}
// Call once from setup()
void begin() {
hal_->serialBegin(115200);
hal_->serialPrintln("WeatherApp started");
last_read_ms_ = hal_->millis();
readAndReport();
}
// Call repeatedly from loop()
void update() {
unsigned long now = hal_->millis();
if (now - last_read_ms_ >= READ_INTERVAL_MS) {
readAndReport();
last_read_ms_ = now;
}
}
// -- Accessors for testing ------------------------------------------------
float lastCelsius() const { return last_celsius_; }
float lastFahrenheit() const { return last_fahrenheit_; }
int readCount() const { return read_count_; }
private:
void readAndReport() {
last_celsius_ = sensor_->readCelsius();
last_fahrenheit_ = sensor_->readFahrenheit();
read_count_++;
// Format: "Temperature: 23.5 C (74.3 F)"
// Use integer math for AVR compatibility (no %f on AVR printf)
char buf[48];
formatOneDp(buf, sizeof(buf), last_celsius_);
hal_->serialPrint("Temperature: ");
hal_->serialPrint(buf);
hal_->serialPrint(" C (");
formatOneDp(buf, sizeof(buf), last_fahrenheit_);
hal_->serialPrint(buf);
hal_->serialPrintln(" F)");
}
// Format a float as "23.5" or "-5.3" using integer math only.
static void formatOneDp(char* buf, size_t len, float value) {
int whole = (int)value;
int frac = (int)(value * 10.0f) % 10;
if (frac < 0) frac = -frac;
// Handle -0.x case (e.g. -0.3 C)
if (value < 0.0f && whole == 0) {
snprintf(buf, len, "-0.%d", frac);
} else {
snprintf(buf, len, "%d.%d", whole, frac);
}
}
Hal* hal_;
TempSensor* sensor_;
unsigned long last_read_ms_;
float last_celsius_;
float last_fahrenheit_;
int read_count_;
};
#endif // APP_H

View File

@@ -0,0 +1,25 @@
[template]
name = "weather"
base = "basic"
description = "Weather station with TMP36 temperature sensor"
[requires]
libraries = ["tmp36"]
board_capabilities = ["analog"]
# Default pin assignments per board.
# "default" is used when no board-specific section exists.
[pins.default]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.uno]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.mega]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.nano]
tmp36_data = { pin = "A0", mode = "analog" }
[pins.leonardo]
tmp36_data = { pin = "A0", mode = "analog" }

View File

@@ -0,0 +1,119 @@
#include <gtest/gtest.h>
#include "mock_arduino.h"
#include "hal.h"
#include "sim_hal.h"
#include "tmp36_sim.h"
#include "{{PROJECT_NAME}}_app.h"
// ============================================================================
// System Tests -- exercise WeatherApp with simulated sensor and hardware
//
// These tests use SimHal (simulated GPIO, timing, serial) and Tmp36Sim
// (simulated analog sensor with noise). No mocking expectations -- we
// observe real behavior through SimHal's inspection API.
// ============================================================================
class WeatherSystemTest : public ::testing::Test {
protected:
void SetUp() override {
mock_arduino_reset();
sim_.setMillis(0);
}
SimHal sim_;
Tmp36Sim sensor_{25.0f}; // 25 C base temperature
};
TEST_F(WeatherSystemTest, StartsAndPrintsToSerial) {
WeatherApp app(&sim_, &sensor_);
app.begin();
std::string output = sim_.serialOutput();
EXPECT_NE(output.find("WeatherApp started"), std::string::npos);
EXPECT_NE(output.find("Temperature:"), std::string::npos);
}
TEST_F(WeatherSystemTest, InitialReadingIsReasonable) {
Tmp36Sim exact_sensor(25.0f, 0.0f); // zero noise
WeatherApp app(&sim_, &exact_sensor);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 25.0f, 1.0f);
EXPECT_EQ(app.readCount(), 1);
}
TEST_F(WeatherSystemTest, ReadsAtTwoSecondIntervals) {
WeatherApp app(&sim_, &sensor_);
app.begin();
EXPECT_EQ(app.readCount(), 1);
// 1 second -- no new reading
sim_.advanceMillis(1000);
app.update();
EXPECT_EQ(app.readCount(), 1);
// 2 seconds -- new reading
sim_.advanceMillis(1000);
app.update();
EXPECT_EQ(app.readCount(), 2);
// 4 seconds -- another reading
sim_.advanceMillis(2000);
app.update();
EXPECT_EQ(app.readCount(), 3);
}
TEST_F(WeatherSystemTest, FiveMinuteRun) {
WeatherApp app(&sim_, &sensor_);
app.begin();
// Run 5 minutes at 100ms resolution
for (int i = 0; i < 3000; ++i) {
sim_.advanceMillis(100);
app.update();
}
// 5 minutes = 300 seconds / 2 second interval = 150 readings + 1 initial
EXPECT_EQ(app.readCount(), 151);
}
TEST_F(WeatherSystemTest, TemperatureChangeMidRun) {
Tmp36Sim sensor(20.0f, 0.0f); // start at 20 C, no noise
WeatherApp app(&sim_, &sensor);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 20.0f, 1.0f);
// Change temperature and wait for next reading
sensor.setBaseTemperature(35.0f);
sim_.advanceMillis(2000);
app.update();
EXPECT_NEAR(app.lastCelsius(), 35.0f, 1.0f);
}
TEST_F(WeatherSystemTest, SerialOutputContainsFahrenheit) {
Tmp36Sim exact_sensor(0.0f, 0.0f); // 0 C = 32 F
WeatherApp app(&sim_, &exact_sensor);
app.begin();
std::string output = sim_.serialOutput();
EXPECT_NE(output.find("32"), std::string::npos)
<< "Should contain 32 F for 0 C: " << output;
}
TEST_F(WeatherSystemTest, NoisyReadingsStayInRange) {
Tmp36Sim noisy_sensor(25.0f, 2.0f); // +/- 2 C noise
noisy_sensor.setSeed(42);
WeatherApp app(&sim_, &noisy_sensor);
for (int i = 0; i < 20; ++i) {
sim_.setMillis(i * 2000);
if (i == 0) app.begin(); else app.update();
float c = app.lastCelsius();
EXPECT_GE(c, 20.0f) << "Reading " << i << " too low: " << c;
EXPECT_LE(c, 30.0f) << "Reading " << i << " too high: " << c;
}
}

View File

@@ -0,0 +1,132 @@
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "hal.h"
#include "mock_hal.h"
#include "tmp36_mock.h"
#include "{{PROJECT_NAME}}_app.h"
using ::testing::_;
using ::testing::AnyNumber;
using ::testing::Return;
using ::testing::HasSubstr;
// ============================================================================
// Unit Tests -- verify WeatherApp behavior with mock sensor
// ============================================================================
class WeatherUnitTest : public ::testing::Test {
protected:
void SetUp() override {
ON_CALL(mock_, millis()).WillByDefault(Return(0));
EXPECT_CALL(mock_, serialBegin(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrint(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrintln(_)).Times(AnyNumber());
EXPECT_CALL(mock_, millis()).Times(AnyNumber());
}
::testing::NiceMock<MockHal> mock_;
Tmp36Mock sensor_;
};
TEST_F(WeatherUnitTest, BeginPrintsStartupMessage) {
WeatherApp app(&mock_, &sensor_);
EXPECT_CALL(mock_, serialBegin(115200)).Times(1);
EXPECT_CALL(mock_, serialPrintln(HasSubstr("WeatherApp started"))).Times(1);
app.begin();
}
TEST_F(WeatherUnitTest, BeginTakesInitialReading) {
sensor_.setTemperature(25.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_EQ(app.readCount(), 1);
EXPECT_NEAR(app.lastCelsius(), 25.0f, 0.1f);
}
TEST_F(WeatherUnitTest, ReadsAfterInterval) {
sensor_.setTemperature(20.0f);
WeatherApp app(&mock_, &sensor_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
EXPECT_EQ(app.readCount(), 1);
// Not enough time yet
ON_CALL(mock_, millis()).WillByDefault(Return(1999));
app.update();
EXPECT_EQ(app.readCount(), 1);
// Now 2 seconds have passed
ON_CALL(mock_, millis()).WillByDefault(Return(2000));
app.update();
EXPECT_EQ(app.readCount(), 2);
}
TEST_F(WeatherUnitTest, DoesNotReadTooEarly) {
sensor_.setTemperature(22.0f);
WeatherApp app(&mock_, &sensor_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
ON_CALL(mock_, millis()).WillByDefault(Return(1500));
app.update();
EXPECT_EQ(app.readCount(), 1);
}
TEST_F(WeatherUnitTest, CelsiusToFahrenheitConversion) {
sensor_.setTemperature(0.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 0.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 32.0f, 0.1f);
}
TEST_F(WeatherUnitTest, BoilingPoint) {
sensor_.setTemperature(100.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 100.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 212.0f, 0.1f);
}
TEST_F(WeatherUnitTest, NegativeTemperature) {
sensor_.setTemperature(-10.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), -10.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 14.0f, 0.1f);
}
TEST_F(WeatherUnitTest, PrintsTemperatureOnRead) {
sensor_.setTemperature(25.0f);
WeatherApp app(&mock_, &sensor_);
EXPECT_CALL(mock_, serialPrint(HasSubstr("Temperature: "))).Times(1);
EXPECT_CALL(mock_, serialPrintln(HasSubstr(" F)"))).Times(1);
app.begin();
}
TEST_F(WeatherUnitTest, MultipleReadingsTrackNewTemperature) {
WeatherApp app(&mock_, &sensor_);
sensor_.setTemperature(20.0f);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
EXPECT_NEAR(app.lastCelsius(), 20.0f, 0.1f);
sensor_.setTemperature(30.0f);
ON_CALL(mock_, millis()).WillByDefault(Return(2000));
app.update();
EXPECT_NEAR(app.lastCelsius(), 30.0f, 0.1f);
EXPECT_EQ(app.readCount(), 2);
}

View File

@@ -0,0 +1,750 @@
use anvil::commands;
use anvil::library;
use anvil::project::config::ProjectConfig;
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use tempfile::TempDir;
// =========================================================================
// Template registry
// =========================================================================
#[test]
fn test_list_templates_includes_weather() {
let templates = TemplateManager::list_templates();
assert!(
templates.iter().any(|t| t.name == "weather"),
"Weather template should be listed"
);
}
#[test]
fn test_weather_template_exists() {
assert!(TemplateManager::template_exists("weather"));
}
#[test]
fn test_weather_is_not_default() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(!weather.is_default);
}
#[test]
fn test_weather_lists_tmp36_library() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(weather.libraries.contains(&"tmp36".to_string()));
}
#[test]
fn test_weather_lists_analog_capability() {
let templates = TemplateManager::list_templates();
let weather = templates.iter().find(|t| t.name == "weather").unwrap();
assert!(weather.board_capabilities.contains(&"analog".to_string()));
}
// =========================================================================
// Composed metadata
// =========================================================================
#[test]
fn test_weather_composed_meta_exists() {
let meta = TemplateManager::composed_meta("weather");
assert!(meta.is_some(), "Weather should have composed metadata");
}
#[test]
fn test_weather_composed_meta_base_is_basic() {
let meta = TemplateManager::composed_meta("weather").unwrap();
assert_eq!(meta.base, "basic");
}
#[test]
fn test_weather_composed_meta_requires_tmp36() {
let meta = TemplateManager::composed_meta("weather").unwrap();
assert!(meta.libraries.contains(&"tmp36".to_string()));
}
#[test]
fn test_weather_composed_meta_has_pin_defaults() {
let meta = TemplateManager::composed_meta("weather").unwrap();
assert!(!meta.pins.is_empty());
}
#[test]
fn test_weather_pins_for_uno() {
let meta = TemplateManager::composed_meta("weather").unwrap();
let pins = meta.pins_for_board("uno");
assert_eq!(pins.len(), 1);
assert_eq!(pins[0].name, "tmp36_data");
assert_eq!(pins[0].pin, "A0");
assert_eq!(pins[0].mode, "analog");
}
#[test]
fn test_weather_pins_fallback_to_default() {
let meta = TemplateManager::composed_meta("weather").unwrap();
// "micro" is not explicitly listed, should fall back to "default"
let pins = meta.pins_for_board("micro");
assert!(!pins.is_empty());
assert!(pins.iter().any(|p| p.name == "tmp36_data"));
}
#[test]
fn test_basic_has_no_composed_meta() {
assert!(TemplateManager::composed_meta("basic").is_none());
}
// =========================================================================
// Template extraction -- file overlay
// =========================================================================
fn extract_weather(name: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: name.to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
tmp
}
#[test]
fn test_weather_has_basic_scaffold() {
let tmp = extract_weather("wx");
assert!(tmp.path().join(".anvil.toml").exists());
assert!(tmp.path().join("build.sh").exists());
assert!(tmp.path().join("build.bat").exists());
assert!(tmp.path().join("upload.sh").exists());
assert!(tmp.path().join("monitor.sh").exists());
assert!(tmp.path().join(".gitignore").exists());
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
}
#[test]
fn test_weather_has_weather_app() {
let tmp = extract_weather("wx");
let app_path = tmp.path().join("lib").join("app").join("wx_app.h");
assert!(app_path.exists(), "Weather app header should exist");
let content = fs::read_to_string(&app_path).unwrap();
assert!(content.contains("WeatherApp"));
assert!(content.contains("TempSensor"));
assert!(content.contains("readCelsius"));
assert!(content.contains("readFahrenheit"));
assert!(content.contains("READ_INTERVAL_MS"));
}
#[test]
fn test_weather_app_replaces_basic_blink() {
let tmp = extract_weather("wx");
let app_path = tmp.path().join("lib").join("app").join("wx_app.h");
let content = fs::read_to_string(&app_path).unwrap();
// Should NOT contain basic template's BlinkApp
assert!(
!content.contains("BlinkApp"),
"Weather app should replace basic blink, not include it"
);
}
#[test]
fn test_weather_has_weather_sketch() {
let tmp = extract_weather("wx");
let ino_path = tmp.path().join("wx").join("wx.ino");
assert!(ino_path.exists());
let content = fs::read_to_string(&ino_path).unwrap();
assert!(content.contains("Tmp36Analog"));
assert!(content.contains("WeatherApp"));
assert!(content.contains("hal_arduino.h"));
assert!(content.contains("tmp36_analog.h"));
}
#[test]
fn test_weather_sketch_replaces_basic_sketch() {
let tmp = extract_weather("wx");
let ino_path = tmp.path().join("wx").join("wx.ino");
let content = fs::read_to_string(&ino_path).unwrap();
assert!(
!content.contains("BlinkApp"),
"Weather sketch should replace basic, not extend it"
);
}
#[test]
fn test_weather_has_unit_tests() {
let tmp = extract_weather("wx");
let test_path = tmp.path().join("test").join("test_unit.cpp");
assert!(test_path.exists());
let content = fs::read_to_string(&test_path).unwrap();
assert!(content.contains("Tmp36Mock"));
assert!(content.contains("WeatherUnitTest"));
assert!(content.contains("wx_app.h"));
}
#[test]
fn test_weather_has_system_tests() {
let tmp = extract_weather("wx");
let test_path = tmp.path().join("test").join("test_system.cpp");
assert!(test_path.exists());
let content = fs::read_to_string(&test_path).unwrap();
assert!(content.contains("Tmp36Sim"));
assert!(content.contains("WeatherSystemTest"));
assert!(content.contains("SimHal"));
}
#[test]
fn test_weather_no_template_toml_in_output() {
let tmp = extract_weather("wx");
assert!(
!tmp.path().join("template.toml").exists(),
"template.toml should be stripped from output"
);
}
#[test]
fn test_weather_preserves_cmake() {
let tmp = extract_weather("wx");
let cmake = tmp.path().join("test").join("CMakeLists.txt");
assert!(cmake.exists());
let content = fs::read_to_string(&cmake).unwrap();
assert!(content.contains("wx"));
}
#[test]
fn test_weather_variable_substitution() {
let tmp = extract_weather("my_weather");
// App header should use project name
let app_path = tmp
.path()
.join("lib")
.join("app")
.join("my_weather_app.h");
assert!(app_path.exists());
// Sketch should use project name
let ino_path = tmp
.path()
.join("my_weather")
.join("my_weather.ino");
assert!(ino_path.exists());
// Config should use project name
let config = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
assert!(config.contains("my_weather"));
}
#[test]
fn test_weather_all_files_ascii() {
let tmp = extract_weather("wx");
let mut checked = 0;
for entry in walkdir(tmp.path()) {
let content = fs::read(&entry).unwrap();
for (i, &byte) in content.iter().enumerate() {
assert!(
byte < 128,
"Non-ASCII byte {} at offset {} in {}",
byte,
i,
entry.display()
);
}
checked += 1;
}
assert!(checked > 0, "Should have checked some files");
}
/// Walk all files in a directory recursively.
fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut files = vec![];
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path));
} else {
files.push(path);
}
}
}
files
}
// =========================================================================
// Library installation via install_library
// =========================================================================
#[test]
fn test_install_library_adds_tmp36_to_project() {
let tmp = extract_weather("wx");
let written =
commands::lib::install_library("tmp36", tmp.path()).unwrap();
assert!(!written.is_empty(), "Should write at least one file");
// Verify driver directory created
let driver_dir = tmp.path().join("lib").join("drivers").join("tmp36");
assert!(driver_dir.exists());
assert!(driver_dir.join("tmp36.h").exists());
assert!(driver_dir.join("tmp36_analog.h").exists());
assert!(driver_dir.join("tmp36_mock.h").exists());
assert!(driver_dir.join("tmp36_sim.h").exists());
// Verify config updated
let config = ProjectConfig::load(tmp.path()).unwrap();
assert!(config.libraries.contains_key("tmp36"));
assert!(config.build.include_dirs.contains(
&"lib/drivers/tmp36".to_string()
));
}
#[test]
fn test_install_library_idempotent() {
let tmp = extract_weather("wx");
let first =
commands::lib::install_library("tmp36", tmp.path()).unwrap();
assert!(!first.is_empty());
// Second call should be a no-op
let second =
commands::lib::install_library("tmp36", tmp.path()).unwrap();
assert!(second.is_empty(), "Second install should skip");
}
// =========================================================================
// Pin assignment via install_pin_assignment
// =========================================================================
#[test]
fn test_install_pin_assignment_creates_assignment() {
let tmp = extract_weather("wx");
// Need library installed first (for the include_dirs, not strictly
// needed for pin assignment but mirrors real flow)
commands::lib::install_library("tmp36", tmp.path()).unwrap();
commands::pin::install_pin_assignment(
"tmp36_data",
"A0",
"analog",
"uno",
tmp.path(),
)
.unwrap();
// Verify config has the assignment
let config = ProjectConfig::load(tmp.path()).unwrap();
let pins = config.pins.get("uno").expect("Should have uno pins");
assert!(pins.assignments.contains_key("tmp36_data"));
let a = &pins.assignments["tmp36_data"];
assert_eq!(a.pin, 14); // A0 = pin 14 on Uno
assert_eq!(a.mode, "analog");
}
#[test]
fn test_install_pin_assignment_rejects_bad_pin() {
let tmp = extract_weather("wx");
let result = commands::pin::install_pin_assignment(
"tmp36_data",
"Z99",
"analog",
"uno",
tmp.path(),
);
assert!(result.is_err());
}
#[test]
fn test_install_pin_assignment_rejects_bad_mode() {
let tmp = extract_weather("wx");
let result = commands::pin::install_pin_assignment(
"tmp36_data",
"A0",
"bogus",
"uno",
tmp.path(),
);
assert!(result.is_err());
}
// =========================================================================
// Full composed template flow (extract + install libs + assign pins)
// =========================================================================
#[test]
fn test_weather_full_flow() {
let tmp = extract_weather("my_wx");
// Install libraries (as the new command would)
let meta = TemplateManager::composed_meta("weather").unwrap();
for lib_name in &meta.libraries {
commands::lib::install_library(lib_name, tmp.path()).unwrap();
}
// Assign pins (as the new command would)
let pin_defaults = meta.pins_for_board("uno");
for pin_def in &pin_defaults {
commands::pin::install_pin_assignment(
&pin_def.name,
&pin_def.pin,
&pin_def.mode,
"uno",
tmp.path(),
)
.unwrap();
}
// Verify everything is in place
let config = ProjectConfig::load(tmp.path()).unwrap();
// Library installed
assert!(config.libraries.contains_key("tmp36"));
assert!(config.build.include_dirs.contains(
&"lib/drivers/tmp36".to_string()
));
// Pin assigned
let pins = config.pins.get("uno").expect("Should have uno pins");
assert!(pins.assignments.contains_key("tmp36_data"));
// Driver files present
assert!(tmp
.path()
.join("lib")
.join("drivers")
.join("tmp36")
.join("tmp36.h")
.exists());
// App code present (weather-specific, not blink)
let app_content = fs::read_to_string(
tmp.path().join("lib").join("app").join("my_wx_app.h"),
)
.unwrap();
assert!(app_content.contains("WeatherApp"));
assert!(!app_content.contains("BlinkApp"));
// Test files present (weather-specific)
let unit_content = fs::read_to_string(
tmp.path().join("test").join("test_unit.cpp"),
)
.unwrap();
assert!(unit_content.contains("Tmp36Mock"));
// Sketch wires everything together
let ino_content = fs::read_to_string(
tmp.path().join("my_wx").join("my_wx.ino"),
)
.unwrap();
assert!(ino_content.contains("Tmp36Analog"));
assert!(ino_content.contains("WeatherApp"));
}
#[test]
fn test_weather_flow_with_mega() {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: "mega_wx".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("weather", tmp.path(), &ctx).unwrap();
let meta = TemplateManager::composed_meta("weather").unwrap();
for lib_name in &meta.libraries {
commands::lib::install_library(lib_name, tmp.path()).unwrap();
}
let pin_defaults = meta.pins_for_board("mega");
for pin_def in &pin_defaults {
commands::pin::install_pin_assignment(
&pin_def.name,
&pin_def.pin,
&pin_def.mode,
"mega",
tmp.path(),
)
.unwrap();
}
let config = ProjectConfig::load(tmp.path()).unwrap();
assert!(config.libraries.contains_key("tmp36"));
let pins = config.pins.get("mega").expect("Should have mega pins");
assert!(pins.assignments.contains_key("tmp36_data"));
}
// =========================================================================
// Audit integration -- after template creation, audit should be clean
// =========================================================================
#[test]
fn test_weather_audit_clean_after_full_setup() {
let tmp = extract_weather("wx");
let meta = TemplateManager::composed_meta("weather").unwrap();
for lib_name in &meta.libraries {
commands::lib::install_library(lib_name, tmp.path()).unwrap();
}
for pin_def in meta.pins_for_board("uno") {
commands::pin::install_pin_assignment(
&pin_def.name,
&pin_def.pin,
&pin_def.mode,
"uno",
tmp.path(),
)
.unwrap();
}
// Load config and check that library pins are all assigned
let config = ProjectConfig::load(tmp.path()).unwrap();
let lib_meta = library::find_library("tmp36").unwrap();
let pins = config.pins.get("uno").unwrap();
let assigned_names: Vec<String> =
pins.assignments.keys().cloned().collect();
let unassigned =
library::unassigned_pins(&lib_meta, &assigned_names);
assert!(
unassigned.is_empty(),
"All library pins should be assigned after full setup, \
but these are missing: {:?}",
unassigned
);
}
// =========================================================================
// API compatibility -- template C++ must match library headers
// =========================================================================
/// Extract public method names from a C++ header (simple regex-free scan).
/// Looks for lines like: "void methodName(" or "float methodName(" or
/// "ClassName(" (constructors).
fn extract_public_methods(header: &str) -> Vec<String> {
let mut methods = Vec::new();
for line in header.lines() {
let trimmed = line.trim();
// Skip comments, preprocessor, blank
if trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with("*")
|| trimmed.starts_with('#')
|| trimmed.is_empty()
{
continue;
}
// Look for "word(" patterns that are method declarations
// e.g. "void setBaseTemperature(float celsius)"
// e.g. "Tmp36Sim(float base_temp = 22.0f, float noise = 0.5f)"
if let Some(paren_pos) = trimmed.find('(') {
let before = trimmed[..paren_pos].trim();
// Last word before the paren is the method name
if let Some(name) = before.split_whitespace().last() {
// Skip class/struct declarations
if name == "class" || name == "struct" || name == "if"
|| name == "for" || name == "while"
|| name == "override"
{
continue;
}
methods.push(name.to_string());
}
}
}
methods
}
/// Count the number of default parameters in a constructor signature.
/// e.g. "Tmp36Sim(float base_temp = 22.0f, float noise = 0.5f)"
/// has 2 params, both with defaults.
fn count_constructor_params(header: &str, class_name: &str) -> (usize, usize) {
for line in header.lines() {
let trimmed = line.trim();
if !trimmed.contains(&format!("{}(", class_name)) {
continue;
}
if let Some(start) = trimmed.find('(') {
if let Some(end) = trimmed.find(')') {
let params_str = &trimmed[start + 1..end];
if params_str.trim().is_empty() {
return (0, 0);
}
let params: Vec<&str> = params_str.split(',').collect();
let total = params.len();
let with_defaults =
params.iter().filter(|p| p.contains('=')).count();
return (total, with_defaults);
}
}
}
(0, 0)
}
#[test]
fn test_template_system_tests_use_valid_sim_api() {
let tmp = extract_weather("wx");
commands::lib::install_library("tmp36", tmp.path()).unwrap();
let sim_header = fs::read_to_string(
tmp.path()
.join("lib")
.join("drivers")
.join("tmp36")
.join("tmp36_sim.h"),
)
.unwrap();
let test_source = fs::read_to_string(
tmp.path().join("test").join("test_system.cpp"),
)
.unwrap();
let methods = extract_public_methods(&sim_header);
// Verify test code only calls methods that exist in the header
// Check for common method-call patterns: "sensor.methodName("
// or "exact_sensor.methodName(" etc.
for line in test_source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.is_empty() {
continue;
}
// Find "identifier.methodName(" patterns
if let Some(dot_pos) = trimmed.find('.') {
let after_dot = &trimmed[dot_pos + 1..];
if let Some(paren_pos) = after_dot.find('(') {
let method_name = after_dot[..paren_pos].trim();
// Only check methods on sensor/sim-like objects
let before_dot = trimmed[..dot_pos].trim();
let before_dot = before_dot
.split_whitespace()
.last()
.unwrap_or(before_dot);
if before_dot.contains("sensor") || before_dot.contains("sim") {
assert!(
methods.contains(&method_name.to_string()),
"test_system.cpp calls '{}.{}()' but '{}' \
is not in tmp36_sim.h.\n \
Available methods: {:?}",
before_dot,
method_name,
method_name,
methods
);
}
}
}
}
}
#[test]
fn test_template_unit_tests_use_valid_mock_api() {
let tmp = extract_weather("wx");
commands::lib::install_library("tmp36", tmp.path()).unwrap();
let mock_header = fs::read_to_string(
tmp.path()
.join("lib")
.join("drivers")
.join("tmp36")
.join("tmp36_mock.h"),
)
.unwrap();
let test_source = fs::read_to_string(
tmp.path().join("test").join("test_unit.cpp"),
)
.unwrap();
let methods = extract_public_methods(&mock_header);
for line in test_source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.is_empty() {
continue;
}
if let Some(dot_pos) = trimmed.find('.') {
let after_dot = &trimmed[dot_pos + 1..];
if let Some(paren_pos) = after_dot.find('(') {
let method_name = after_dot[..paren_pos].trim();
let before_dot = trimmed[..dot_pos].trim();
let before_dot = before_dot
.split_whitespace()
.last()
.unwrap_or(before_dot);
if before_dot.contains("sensor") {
assert!(
methods.contains(&method_name.to_string()),
"test_unit.cpp calls '{}.{}()' but '{}' \
is not in tmp36_mock.h.\n \
Available methods: {:?}",
before_dot,
method_name,
method_name,
methods
);
}
}
}
}
}
#[test]
fn test_template_sim_constructor_arg_count() {
let tmp = extract_weather("wx");
commands::lib::install_library("tmp36", tmp.path()).unwrap();
let sim_header = fs::read_to_string(
tmp.path()
.join("lib")
.join("drivers")
.join("tmp36")
.join("tmp36_sim.h"),
)
.unwrap();
let (total_params, default_params) =
count_constructor_params(&sim_header, "Tmp36Sim");
let test_source = fs::read_to_string(
tmp.path().join("test").join("test_system.cpp"),
)
.unwrap();
// Check every "Tmp36Sim(" call in test code
let min_args = total_params - default_params;
for (line_num, line) in test_source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") {
continue;
}
// Find "Tmp36Sim(" constructor calls (not #include or class decl)
if let Some(pos) = trimmed.find("Tmp36Sim(") {
// Skip if it's a forward decl or class line
if trimmed.contains("class ") || trimmed.contains("#include") {
continue;
}
let after = &trimmed[pos + 9..]; // after "Tmp36Sim("
if let Some(close) = after.find(')') {
let args_str = &after[..close];
let arg_count = if args_str.trim().is_empty() {
0
} else {
args_str.split(',').count()
};
assert!(
arg_count >= min_args && arg_count <= total_params,
"test_system.cpp line {}: Tmp36Sim() called with \
{} args, but constructor accepts {}-{} args.\n \
Line: {}",
line_num + 1,
arg_count,
min_args,
total_params,
trimmed
);
}
}
}
}