Trying to fix tests in project, not done yet
This commit is contained in:
@@ -5,6 +5,41 @@ use std::path::PathBuf;
|
|||||||
use crate::library;
|
use crate::library;
|
||||||
use crate::project::config::ProjectConfig;
|
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.
|
/// Add a library to the current project.
|
||||||
pub fn add_library(name: &str, pin: Option<&str>, project_dir: Option<&str>) -> Result<()> {
|
pub fn add_library(name: &str, pin: Option<&str>, project_dir: Option<&str>) -> Result<()> {
|
||||||
let meta = library::find_library(name)
|
let meta = library::find_library(name)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use anyhow::{Result, bail};
|
|||||||
use colored::*;
|
use colored::*;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::board::pinmap;
|
||||||
use crate::board::presets::{self, BoardPreset};
|
use crate::board::presets::{self, BoardPreset};
|
||||||
use crate::templates::{TemplateManager, TemplateContext};
|
use crate::templates::{TemplateManager, TemplateContext};
|
||||||
use crate::version::ANVIL_VERSION;
|
use crate::version::ANVIL_VERSION;
|
||||||
@@ -18,12 +19,43 @@ pub fn list_templates() -> Result<()> {
|
|||||||
marker.bright_cyan()
|
marker.bright_cyan()
|
||||||
);
|
);
|
||||||
println!(" {}", info.description);
|
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!();
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{}", "Usage:".bright_yellow().bold());
|
println!("{}", "Usage:".bright_yellow().bold());
|
||||||
println!(" anvil new <project-name>");
|
println!(" anvil new <project-name>");
|
||||||
println!(" anvil new <project-name> --template basic");
|
println!(
|
||||||
|
" anvil new <project-name> --template <name> --board <board>"
|
||||||
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -58,12 +90,10 @@ pub fn list_boards() -> Result<()> {
|
|||||||
println!();
|
println!();
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
"For boards not listed here, create a project and then:".bright_black()
|
"For boards not listed here, create a project and then:"
|
||||||
);
|
.bright_black()
|
||||||
println!(
|
|
||||||
" {}",
|
|
||||||
" anvil board --listall".bright_black()
|
|
||||||
);
|
);
|
||||||
|
println!(" {}", " anvil board --listall".bright_black());
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -90,7 +120,9 @@ pub fn create_project(
|
|||||||
if !TemplateManager::template_exists(template_name) {
|
if !TemplateManager::template_exists(template_name) {
|
||||||
println!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format!("Template '{}' not found.", template_name).red().bold()
|
format!("Template '{}' not found.", template_name)
|
||||||
|
.red()
|
||||||
|
.bold()
|
||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
list_templates()?;
|
list_templates()?;
|
||||||
@@ -99,8 +131,7 @@ pub fn create_project(
|
|||||||
|
|
||||||
// Resolve board preset
|
// Resolve board preset
|
||||||
let preset: &BoardPreset = match board {
|
let preset: &BoardPreset = match board {
|
||||||
Some(b) => {
|
Some(b) => match presets::find_preset(b) {
|
||||||
match presets::find_preset(b) {
|
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
println!(
|
println!(
|
||||||
@@ -111,28 +142,36 @@ pub fn create_project(
|
|||||||
list_boards()?;
|
list_boards()?;
|
||||||
bail!("Invalid board preset");
|
bail!("Invalid board preset");
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
|
||||||
None => presets::find_preset(presets::DEFAULT_PRESET).unwrap(),
|
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!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
format!("Creating Arduino project: {}", name)
|
format!("Creating Arduino project: {}", name)
|
||||||
.bright_green()
|
.bright_green()
|
||||||
.bold()
|
.bold()
|
||||||
);
|
);
|
||||||
println!("{}", format!("Template: {}", template_name).bright_cyan());
|
|
||||||
println!(
|
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!();
|
println!();
|
||||||
|
|
||||||
// Create project directory
|
// Create project directory
|
||||||
std::fs::create_dir_all(&project_path)?;
|
std::fs::create_dir_all(&project_path)?;
|
||||||
|
|
||||||
// Extract template
|
// Extract template (basic scaffold + overlay for composed templates)
|
||||||
println!("{}", "Extracting template files...".bright_yellow());
|
println!("{}", "Extracting template files...".bright_yellow());
|
||||||
let context = TemplateContext {
|
let context = TemplateContext {
|
||||||
project_name: name.to_string(),
|
project_name: name.to_string(),
|
||||||
@@ -142,9 +181,74 @@ pub fn create_project(
|
|||||||
baud: preset.baud,
|
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);
|
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
|
// Make shell scripts executable on Unix
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -174,11 +278,52 @@ pub fn create_project(
|
|||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
print_next_steps(name);
|
print_next_steps(name, template_name);
|
||||||
|
|
||||||
Ok(())
|
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<()> {
|
fn validate_project_name(name: &str) -> Result<()> {
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
bail!("Project name cannot be 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!("{}", "Next steps:".bright_yellow().bold());
|
||||||
println!(
|
println!(
|
||||||
" 1. {}",
|
" 1. {}",
|
||||||
format!("cd {}", project_name).bright_cyan()
|
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") {
|
if cfg!(target_os = "windows") {
|
||||||
println!(
|
println!(
|
||||||
" 2. Compile: {}",
|
" 2. Compile: {}",
|
||||||
@@ -324,10 +503,12 @@ fn print_next_steps(project_name: &str) {
|
|||||||
println!();
|
println!();
|
||||||
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()
|
.bright_black()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
" {}",
|
" {}",
|
||||||
|
|||||||
@@ -237,6 +237,43 @@ pub fn assign_pin(
|
|||||||
Ok(())
|
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
|
// Assign a bus group
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
use include_dir::{include_dir, Dir};
|
use include_dir::{include_dir, Dir};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use anyhow::{Result, bail, Context};
|
use std::collections::HashMap;
|
||||||
|
use anyhow::{Result, Context};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::version::ANVIL_VERSION;
|
use crate::version::ANVIL_VERSION;
|
||||||
|
|
||||||
|
// Embedded template directories
|
||||||
static BASIC_TEMPLATE: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates/basic");
|
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 struct TemplateContext {
|
||||||
pub project_name: String,
|
pub project_name: String,
|
||||||
pub anvil_version: String,
|
pub anvil_version: String,
|
||||||
@@ -15,87 +20,282 @@ pub struct TemplateContext {
|
|||||||
pub baud: u32,
|
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;
|
pub struct TemplateManager;
|
||||||
|
|
||||||
impl TemplateManager {
|
impl TemplateManager {
|
||||||
|
/// Check if a template name is known.
|
||||||
pub fn template_exists(name: &str) -> bool {
|
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> {
|
pub fn list_templates() -> Vec<TemplateInfo> {
|
||||||
vec![
|
let mut templates = vec![TemplateInfo {
|
||||||
TemplateInfo {
|
|
||||||
name: "basic".to_string(),
|
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,
|
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
|
templates
|
||||||
/// substitution and filename transformations.
|
}
|
||||||
|
|
||||||
|
/// 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(
|
pub fn extract(
|
||||||
template_name: &str,
|
template_name: &str,
|
||||||
output_dir: &Path,
|
output_dir: &Path,
|
||||||
context: &TemplateContext,
|
context: &TemplateContext,
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let template_dir = match template_name {
|
let dir = template_dir(template_name)
|
||||||
"basic" => &BASIC_TEMPLATE,
|
.ok_or_else(|| anyhow::anyhow!("Unknown template: {}", template_name))?;
|
||||||
_ => bail!("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)
|
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,
|
// File extraction
|
||||||
pub description: String,
|
// =========================================================================
|
||||||
pub is_default: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recursively extract a directory from the embedded template.
|
/// Recursively extract files from an embedded directory, applying variable
|
||||||
fn extract_dir(
|
/// substitution to .tmpl files and path transformations.
|
||||||
|
fn extract_dir_filtered(
|
||||||
source: &Dir<'_>,
|
source: &Dir<'_>,
|
||||||
output_base: &Path,
|
output_base: &Path,
|
||||||
relative_prefix: &str,
|
|
||||||
context: &TemplateContext,
|
context: &TemplateContext,
|
||||||
|
skip_filenames: &[&str],
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let mut count = 0;
|
let mut count = 0;
|
||||||
|
|
||||||
for file in source.files() {
|
for file in source.files() {
|
||||||
let file_path = file.path();
|
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
|
// Skip files in the skip list
|
||||||
let output_rel = transform_path(&file_name, &context.project_name);
|
if skip_filenames.iter().any(|&s| s == file_name) {
|
||||||
let output_path = output_base.join(&output_rel);
|
continue;
|
||||||
|
}
|
||||||
// Create parent directories
|
|
||||||
if let Some(parent) = output_path.parent() {
|
let full_path = file_path.to_string_lossy().to_string();
|
||||||
fs::create_dir_all(parent)
|
let output_rel = transform_path(&full_path, &context.project_name);
|
||||||
.context(format!("Failed to create directory: {}", parent.display()))?;
|
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();
|
let contents = file.contents();
|
||||||
|
|
||||||
// Check if this is a template file (.tmpl suffix)
|
|
||||||
if output_rel.ends_with(".tmpl") {
|
if output_rel.ends_with(".tmpl") {
|
||||||
// Variable substitution
|
|
||||||
let text = std::str::from_utf8(contents)
|
let text = std::str::from_utf8(contents)
|
||||||
.context("Template file must be UTF-8")?;
|
.context("Template file must be UTF-8")?;
|
||||||
let processed = substitute_variables(text, context);
|
let processed = substitute_variables(text, context);
|
||||||
|
|
||||||
// Remove .tmpl extension
|
|
||||||
let final_path_str = output_rel.trim_end_matches(".tmpl");
|
let final_path_str = output_rel.trim_end_matches(".tmpl");
|
||||||
let final_path = output_base.join(final_path_str);
|
let final_path = output_base.join(final_path_str);
|
||||||
|
|
||||||
if let Some(parent) = final_path.parent() {
|
if let Some(parent) = final_path.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::write(&final_path, processed)?;
|
fs::write(&final_path, processed)?;
|
||||||
count += 1;
|
count += 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -104,9 +304,9 @@ fn extract_dir(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurse into subdirectories
|
|
||||||
for dir in source.dirs() {
|
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)
|
Ok(count)
|
||||||
@@ -117,12 +317,8 @@ fn extract_dir(
|
|||||||
/// - `__name__` -> project name
|
/// - `__name__` -> project name
|
||||||
fn transform_path(path: &str, project_name: &str) -> String {
|
fn transform_path(path: &str, project_name: &str) -> String {
|
||||||
let mut result = path.to_string();
|
let mut result = path.to_string();
|
||||||
|
|
||||||
// Replace __name__ with project name in all path components
|
|
||||||
result = result.replace("__name__", project_name);
|
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 parts: Vec<&str> = result.split('/').collect();
|
||||||
let transformed: Vec<String> = parts
|
let transformed: Vec<String> = parts
|
||||||
.iter()
|
.iter()
|
||||||
@@ -148,6 +344,10 @@ fn substitute_variables(text: &str, context: &TemplateContext) -> String {
|
|||||||
.replace("{{BAUD}}", &context.baud.to_string())
|
.replace("{{BAUD}}", &context.baud.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Tests
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -182,20 +382,53 @@ mod tests {
|
|||||||
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
fqbn: "arduino:avr:mega:cpu=atmega2560".to_string(),
|
||||||
baud: 9600,
|
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);
|
let output = substitute_variables(input, &ctx);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
output,
|
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]
|
#[test]
|
||||||
fn test_template_exists() {
|
fn test_template_exists() {
|
||||||
assert!(TemplateManager::template_exists("basic"));
|
assert!(TemplateManager::template_exists("basic"));
|
||||||
|
assert!(TemplateManager::template_exists("weather"));
|
||||||
assert!(!TemplateManager::template_exists("nonexistent"));
|
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]
|
#[test]
|
||||||
fn test_extract_basic_template() {
|
fn test_extract_basic_template() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -207,26 +440,15 @@ mod tests {
|
|||||||
baud: 115200,
|
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");
|
assert!(count > 0, "Should extract at least one file");
|
||||||
|
assert!(tmp.path().join(".anvil.toml").exists());
|
||||||
// Verify key files exist
|
|
||||||
assert!(
|
assert!(
|
||||||
tmp.path().join(".anvil.toml").exists(),
|
tmp.path().join("test_proj").join("test_proj.ino").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"
|
|
||||||
);
|
);
|
||||||
|
assert!(tmp.path().join("lib").join("hal").join("hal.h").exists());
|
||||||
|
assert!(tmp.path().join(".gitignore").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -241,12 +463,105 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
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
|
#[test]
|
||||||
let config_content = fs::read_to_string(tmp.path().join(".anvil.toml")).unwrap();
|
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!(
|
assert!(
|
||||||
config_content.contains("my_sensor"),
|
app_content.contains("WeatherApp"),
|
||||||
".anvil.toml should contain project name"
|
"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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
35
templates/weather/__name__/__name__.ino.tmpl
Normal file
35
templates/weather/__name__/__name__.ino.tmpl
Normal 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();
|
||||||
|
}
|
||||||
100
templates/weather/lib/app/__name___app.h.tmpl
Normal file
100
templates/weather/lib/app/__name___app.h.tmpl
Normal 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
|
||||||
25
templates/weather/template.toml
Normal file
25
templates/weather/template.toml
Normal 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" }
|
||||||
119
templates/weather/test/test_system.cpp.tmpl
Normal file
119
templates/weather/test/test_system.cpp.tmpl
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
132
templates/weather/test/test_unit.cpp.tmpl
Normal file
132
templates/weather/test/test_unit.cpp.tmpl
Normal 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);
|
||||||
|
}
|
||||||
750
tests/test_template_weather.rs
Normal file
750
tests/test_template_weather.rs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user