Files
anvil/tests/test_template_weather.rs
2026-02-21 20:52:48 -06:00

750 lines
24 KiB
Rust

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
);
}
}
}
}