1096 lines
34 KiB
Rust
1096 lines
34 KiB
Rust
use anvil::commands;
|
|
use anvil::ignore::{self, AnvilIgnore};
|
|
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_managed_example_tests() {
|
|
let tmp = extract_weather("wx");
|
|
let test_path = tmp.path().join("test").join("test_weather.cpp");
|
|
assert!(test_path.exists(), "Managed test_weather.cpp should exist");
|
|
let content = fs::read_to_string(&test_path).unwrap();
|
|
assert!(content.contains("Tmp36Mock"));
|
|
assert!(content.contains("WeatherUnitTest"));
|
|
assert!(content.contains("Tmp36Sim"));
|
|
assert!(content.contains("WeatherSystemTest"));
|
|
assert!(content.contains("wx_app.h"));
|
|
assert!(content.contains("MANAGED BY ANVIL"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_weather_has_student_unit_starter() {
|
|
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();
|
|
// Should be a minimal starter, not the full weather tests
|
|
assert!(content.contains("Your unit tests go here"));
|
|
assert!(!content.contains("WeatherUnitTest"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_weather_has_student_system_starter() {
|
|
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("Your system tests go here"));
|
|
assert!(!content.contains("WeatherSystemTest"));
|
|
}
|
|
|
|
#[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 weather_content = fs::read_to_string(
|
|
tmp.path().join("test").join("test_weather.cpp"),
|
|
)
|
|
.unwrap();
|
|
assert!(weather_content.contains("Tmp36Mock"));
|
|
assert!(weather_content.contains("Tmp36Sim"));
|
|
|
|
// 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_weather_tests_use_valid_sensor_api() {
|
|
// test_weather.cpp includes BOTH unit tests (Tmp36Mock) and system tests
|
|
// (Tmp36Sim). Each sensor method call must exist in at least one header.
|
|
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 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_weather.cpp"),
|
|
)
|
|
.unwrap();
|
|
|
|
let sim_methods = extract_public_methods(&sim_header);
|
|
let mock_methods = extract_public_methods(&mock_header);
|
|
|
|
// Union of all valid methods across both sensor types
|
|
let mut all_methods: Vec<String> = sim_methods.clone();
|
|
for m in &mock_methods {
|
|
if !all_methods.contains(m) {
|
|
all_methods.push(m.clone());
|
|
}
|
|
}
|
|
|
|
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!(
|
|
all_methods.contains(&method_name.to_string()),
|
|
"test_weather.cpp calls '{}.{}()' but '{}' \
|
|
is not in tmp36_sim.h or tmp36_mock.h.\n \
|
|
Sim methods: {:?}\n \
|
|
Mock methods: {:?}",
|
|
before_dot,
|
|
method_name,
|
|
method_name,
|
|
sim_methods,
|
|
mock_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_weather.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_weather.cpp line {}: Tmp36Sim() called with \
|
|
{} args, but constructor accepts {}-{} args.\n \
|
|
Line: {}",
|
|
line_num + 1,
|
|
arg_count,
|
|
min_args,
|
|
total_params,
|
|
trimmed
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// .anvilignore -- generated defaults and behavior
|
|
// =========================================================================
|
|
|
|
/// Helper: full weather project setup (extract + set template + libs + pins + .anvilignore)
|
|
fn setup_weather_project(name: &str) -> TempDir {
|
|
let tmp = extract_weather(name);
|
|
|
|
// Set template field in config (as new.rs does)
|
|
let mut config = ProjectConfig::load(tmp.path()).unwrap();
|
|
config.project.template = "weather".to_string();
|
|
config.save(tmp.path()).unwrap();
|
|
|
|
// Generate .anvilignore
|
|
ignore::generate_default(tmp.path(), "weather").unwrap();
|
|
|
|
// Install library and assign pins
|
|
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();
|
|
}
|
|
|
|
tmp
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_generated_on_project_creation() {
|
|
let tmp = setup_weather_project("wx");
|
|
assert!(
|
|
tmp.path().join(".anvilignore").exists(),
|
|
".anvilignore should be created"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_protects_student_test_files() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(
|
|
ignore.is_ignored("test/test_unit.cpp"),
|
|
"test_unit.cpp should be protected"
|
|
);
|
|
assert!(
|
|
ignore.is_ignored("test/test_system.cpp"),
|
|
"test_system.cpp should be protected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_protects_app_code() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(
|
|
ignore.is_ignored("lib/app/wx_app.h"),
|
|
"App code should be protected by lib/app/* pattern"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_protects_config() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(
|
|
ignore.is_ignored(".anvil.toml"),
|
|
".anvil.toml should be protected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_does_not_protect_managed_scripts() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(
|
|
!ignore.is_ignored("build.sh"),
|
|
"build.sh should NOT be protected"
|
|
);
|
|
assert!(
|
|
!ignore.is_ignored("test/run_tests.sh"),
|
|
"run_tests.sh should NOT be protected"
|
|
);
|
|
assert!(
|
|
!ignore.is_ignored("test/mocks/mock_hal.h"),
|
|
"mock_hal.h should NOT be protected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_does_not_protect_managed_template_test() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(
|
|
!ignore.is_ignored("test/test_weather.cpp"),
|
|
"test_weather.cpp is managed by Anvil, should NOT be protected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_does_not_protect_library_headers() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(
|
|
!ignore.is_ignored("lib/drivers/tmp36/tmp36.h"),
|
|
"Driver headers are managed, should NOT be protected"
|
|
);
|
|
}
|
|
|
|
// =========================================================================
|
|
// .anvilignore -- add/remove patterns
|
|
// =========================================================================
|
|
|
|
#[test]
|
|
fn test_add_ignore_pattern() {
|
|
let tmp = setup_weather_project("wx");
|
|
ignore::add_pattern(tmp.path(), "test/test_custom.cpp").unwrap();
|
|
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(ignore.is_ignored("test/test_custom.cpp"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_ignore_pattern_no_duplicate() {
|
|
let tmp = setup_weather_project("wx");
|
|
ignore::add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
|
|
|
|
let content =
|
|
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
|
|
// test_unit.cpp appears in the default, adding it again should not duplicate
|
|
let count = content
|
|
.lines()
|
|
.filter(|l| l.trim() == "test/test_unit.cpp")
|
|
.count();
|
|
assert_eq!(count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_ignore_wildcard_pattern() {
|
|
let tmp = setup_weather_project("wx");
|
|
ignore::add_pattern(tmp.path(), "test/my_*.cpp").unwrap();
|
|
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(ignore.is_ignored("test/my_custom_test.cpp"));
|
|
assert!(ignore.is_ignored("test/my_helper.cpp"));
|
|
assert!(!ignore.is_ignored("test/test_weather.cpp"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_ignore_pattern() {
|
|
let tmp = setup_weather_project("wx");
|
|
|
|
// test_unit.cpp is in defaults
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(ignore.is_ignored("test/test_unit.cpp"));
|
|
|
|
// Remove it
|
|
let removed =
|
|
ignore::remove_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
|
|
assert!(removed);
|
|
|
|
// Verify it's gone
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(!ignore.is_ignored("test/test_unit.cpp"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_nonexistent_pattern_returns_false() {
|
|
let tmp = setup_weather_project("wx");
|
|
let removed =
|
|
ignore::remove_pattern(tmp.path(), "nonexistent.cpp").unwrap();
|
|
assert!(!removed);
|
|
}
|
|
|
|
// =========================================================================
|
|
// .anvilignore -- matching_pattern reports which rule matched
|
|
// =========================================================================
|
|
|
|
#[test]
|
|
fn test_matching_pattern_reports_exact() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert_eq!(
|
|
ignore.matching_pattern("test/test_unit.cpp"),
|
|
Some("test/test_unit.cpp")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_matching_pattern_reports_glob() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
// lib/app/* should match lib/app/wx_app.h
|
|
let pattern = ignore.matching_pattern("lib/app/wx_app.h");
|
|
assert_eq!(pattern, Some("lib/app/*"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_matching_pattern_returns_none_for_unignored() {
|
|
let tmp = setup_weather_project("wx");
|
|
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
|
|
assert!(ignore.matching_pattern("build.sh").is_none());
|
|
}
|
|
|
|
// =========================================================================
|
|
// Config tracks template name
|
|
// =========================================================================
|
|
|
|
#[test]
|
|
fn test_config_records_template_name() {
|
|
let tmp = setup_weather_project("wx");
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.project.template, "weather");
|
|
}
|
|
|
|
#[test]
|
|
fn test_config_default_template_is_basic() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let ctx = TemplateContext {
|
|
project_name: "basic_proj".to_string(),
|
|
anvil_version: "1.0.0".to_string(),
|
|
board_name: "uno".to_string(),
|
|
fqbn: "arduino:avr:uno".to_string(),
|
|
baud: 115200,
|
|
};
|
|
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
|
|
|
|
let config = ProjectConfig::load(tmp.path()).unwrap();
|
|
assert_eq!(config.project.template, "basic");
|
|
}
|
|
|
|
// =========================================================================
|
|
// Refresh respects .anvilignore
|
|
// =========================================================================
|
|
|
|
#[test]
|
|
fn test_refresh_does_not_overwrite_ignored_files() {
|
|
let tmp = setup_weather_project("wx");
|
|
|
|
// Modify the student's test_unit.cpp (which is ignored)
|
|
let test_unit = tmp.path().join("test").join("test_unit.cpp");
|
|
fs::write(&test_unit, "// my custom test code\n").unwrap();
|
|
|
|
// Run refresh --force
|
|
commands::refresh::run_refresh(
|
|
Some(tmp.path().to_str().unwrap()),
|
|
true,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Student's file should be untouched
|
|
let content = fs::read_to_string(&test_unit).unwrap();
|
|
assert_eq!(content, "// my custom test code\n");
|
|
}
|
|
|
|
#[test]
|
|
fn test_refresh_updates_managed_template_test() {
|
|
let tmp = setup_weather_project("wx");
|
|
|
|
// Tamper with managed test_weather.cpp
|
|
let test_weather = tmp.path().join("test").join("test_weather.cpp");
|
|
let _original = fs::read_to_string(&test_weather).unwrap();
|
|
fs::write(&test_weather, "// tampered\n").unwrap();
|
|
|
|
// Run refresh --force
|
|
commands::refresh::run_refresh(
|
|
Some(tmp.path().to_str().unwrap()),
|
|
true,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Managed file should be restored
|
|
let content = fs::read_to_string(&test_weather).unwrap();
|
|
assert_ne!(content, "// tampered\n");
|
|
assert!(content.contains("WeatherUnitTest"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_refresh_force_file_overrides_ignore() {
|
|
let tmp = setup_weather_project("wx");
|
|
|
|
// Modify ignored test_unit.cpp
|
|
let test_unit = tmp.path().join("test").join("test_unit.cpp");
|
|
let _original = fs::read_to_string(&test_unit).unwrap();
|
|
fs::write(&test_unit, "// i want this overwritten\n").unwrap();
|
|
|
|
// Run refresh --force --file test/test_unit.cpp
|
|
commands::refresh::run_refresh(
|
|
Some(tmp.path().to_str().unwrap()),
|
|
true,
|
|
Some("test/test_unit.cpp"),
|
|
)
|
|
.unwrap();
|
|
|
|
// File should be restored to template version
|
|
let content = fs::read_to_string(&test_unit).unwrap();
|
|
assert!(
|
|
content.contains("Your unit tests go here"),
|
|
"Should be restored to template starter"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_refresh_updates_library_driver_headers() {
|
|
let tmp = setup_weather_project("wx");
|
|
|
|
// Tamper with a driver header (managed)
|
|
let header = tmp
|
|
.path()
|
|
.join("lib")
|
|
.join("drivers")
|
|
.join("tmp36")
|
|
.join("tmp36.h");
|
|
fs::write(&header, "// tampered\n").unwrap();
|
|
|
|
// Run refresh --force
|
|
commands::refresh::run_refresh(
|
|
Some(tmp.path().to_str().unwrap()),
|
|
true,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Header should be restored
|
|
let content = fs::read_to_string(&header).unwrap();
|
|
assert!(content.contains("TempSensor"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_refresh_freshly_created_project_is_up_to_date() {
|
|
let tmp = setup_weather_project("wx");
|
|
|
|
// Refresh without --force should find nothing to do
|
|
// (just verifying it doesn't error)
|
|
commands::refresh::run_refresh(
|
|
Some(tmp.path().to_str().unwrap()),
|
|
false,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_anvilignore_all_files_ascii() {
|
|
let tmp = setup_weather_project("wx");
|
|
let content =
|
|
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
|
|
for (i, byte) in content.bytes().enumerate() {
|
|
assert!(
|
|
byte < 128,
|
|
"Non-ASCII byte {} at offset {} in .anvilignore",
|
|
byte,
|
|
i
|
|
);
|
|
}
|
|
} |