Down to one test failing

This commit is contained in:
Eric Ratliff
2026-02-21 21:33:27 -06:00
parent 8a72098443
commit a9f729c7de
11 changed files with 3590 additions and 336 deletions

View File

@@ -185,6 +185,17 @@ pub fn create_project(
TemplateManager::extract(template_name, &project_path, &context)?; TemplateManager::extract(template_name, &project_path, &context)?;
println!("{} Extracted {} files", "ok".green(), file_count); println!("{} Extracted {} files", "ok".green(), file_count);
// Record which template was used (for refresh to know what to manage)
{
let mut config =
crate::project::config::ProjectConfig::load(&project_path)?;
config.project.template = template_name.to_string();
config.save(&project_path)?;
}
// Generate .anvilignore with sensible defaults
crate::ignore::generate_default(&project_path, template_name)?;
// For composed templates: install libraries and assign pins // For composed templates: install libraries and assign pins
if let Some(meta) = TemplateManager::composed_meta(template_name) { if let Some(meta) = TemplateManager::composed_meta(template_name) {
// Install required libraries // Install required libraries

View File

@@ -1,39 +1,46 @@
use anyhow::{Result, Context}; use anyhow::{Result, Context};
use colored::*; use colored::*;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::fs; use std::fs;
use crate::ignore::{self, AnvilIgnore};
use crate::project::config::ProjectConfig; use crate::project::config::ProjectConfig;
use crate::templates::{TemplateManager, TemplateContext}; use crate::templates::{TemplateManager, TemplateContext};
use crate::version::ANVIL_VERSION; use crate::version::ANVIL_VERSION;
/// Files that anvil owns and can safely refresh. /// Files that are never refreshable, even if they appear in the template.
/// These are build/deploy infrastructure -- not user source code. /// .anvilignore itself is the user's protection config -- never overwrite it.
const REFRESHABLE_FILES: &[&str] = &[ const NEVER_REFRESH: &[&str] = &[
"build.sh", ".anvilignore",
"build.bat",
"upload.sh",
"upload.bat",
"monitor.sh",
"monitor.bat",
"test.sh",
"test.bat",
"_detect_port.ps1",
"_monitor_filter.ps1",
"test/run_tests.sh",
"test/run_tests.bat",
"test/CMakeLists.txt",
"test/mocks/mock_arduino.h",
"test/mocks/mock_arduino.cpp",
"test/mocks/Arduino.h",
"test/mocks/Wire.h",
"test/mocks/SPI.h",
"test/mocks/mock_hal.h",
"test/mocks/sim_hal.h",
]; ];
pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> { /// Recursively walk a directory and return all file paths relative to `base`.
// Resolve project directory /// Uses forward-slash separators regardless of platform.
fn discover_files(base: &Path, dir: &Path) -> Vec<String> {
let mut files = Vec::new();
let entries = match fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return files,
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(discover_files(base, &path));
} else if let Ok(rel) = path.strip_prefix(base) {
let rel_str = rel.to_string_lossy().replace('\\', "/");
files.push(rel_str);
}
}
files.sort();
files
}
/// Main refresh entry point.
pub fn run_refresh(
project_dir: Option<&str>,
force: bool,
file_override: Option<&str>,
) -> Result<()> {
let project_path = match project_dir { let project_path = match project_dir {
Some(dir) => PathBuf::from(dir), Some(dir) => PathBuf::from(dir),
None => std::env::current_dir() None => std::env::current_dir()
@@ -42,61 +49,101 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
let project_root = ProjectConfig::find_project_root(&project_path)?; let project_root = ProjectConfig::find_project_root(&project_path)?;
let config = ProjectConfig::load(&project_root)?; let config = ProjectConfig::load(&project_root)?;
let anvilignore = AnvilIgnore::load(&project_root)?;
let template_name = &config.project.template;
println!( println!(
"Refreshing project: {}", "Refreshing project: {}",
config.project.name.bright_white().bold() config.project.name.bright_white().bold()
); );
println!(
"Template: {}",
template_name.bright_cyan()
);
println!( println!(
"Project directory: {}", "Project directory: {}",
project_root.display().to_string().bright_black() project_root.display().to_string().bright_black()
); );
println!(); println!();
// Generate fresh copies of all refreshable files from the template // Build template context for extraction
let template_name = "basic";
let context = TemplateContext { let context = TemplateContext {
project_name: config.project.name.clone(), project_name: config.project.name.clone(),
anvil_version: ANVIL_VERSION.to_string(), anvil_version: ANVIL_VERSION.to_string(),
board_name: config.build.default.clone(), board_name: config.build.default.clone(),
fqbn: config.default_fqbn().unwrap_or_else(|_| "arduino:avr:uno".to_string()), fqbn: config
.default_fqbn()
.unwrap_or_else(|_| "arduino:avr:uno".to_string()),
baud: config.monitor.baud, baud: config.monitor.baud,
}; };
// Extract template into a temp directory so we can compare // Extract fresh template + libraries into temp directory
let temp_dir = tempfile::tempdir() let temp_dir =
.context("Failed to create temp directory")?; tempfile::tempdir().context("Failed to create temp directory")?;
TemplateManager::extract(template_name, temp_dir.path(), &context)?; TemplateManager::extract(template_name, temp_dir.path(), &context)?;
// Compare each refreshable file // Also extract library files into temp (for composed templates)
let mut up_to_date = Vec::new(); for lib_name in config.libraries.keys() {
let mut will_create = Vec::new(); let _ = crate::library::extract_library(lib_name, temp_dir.path());
let mut has_changes = Vec::new(); }
// Discover ALL files the template produces
let all_template_files = discover_files(temp_dir.path(), temp_dir.path());
// Normalize the --file override
let file_override_norm = file_override.map(|f| f.replace('\\', "/"));
// Compare each template-produced file
let mut up_to_date = Vec::new();
let mut will_create: Vec<String> = Vec::new();
let mut has_changes: Vec<String> = Vec::new();
let mut ignored: Vec<(String, String)> = Vec::new(); // (file, pattern)
for filename in &all_template_files {
// Skip files that are never refreshable
if NEVER_REFRESH.iter().any(|&nr| nr == filename) {
continue;
}
for &filename in REFRESHABLE_FILES {
let existing = project_root.join(filename); let existing = project_root.join(filename);
let fresh = temp_dir.path().join(filename); let fresh = temp_dir.path().join(filename);
if !fresh.exists() { if !fresh.exists() {
// Template doesn't produce this file (shouldn't happen)
continue; continue;
} }
// Check .anvilignore (unless this file is the --file override)
let is_override = file_override_norm
.as_ref()
.map(|f| f == filename)
.unwrap_or(false);
if !is_override {
if let Some(pattern) = anvilignore.matching_pattern(filename) {
ignored.push((
filename.clone(),
pattern.to_string(),
));
continue;
}
}
let fresh_content = fs::read(&fresh) let fresh_content = fs::read(&fresh)
.context(format!("Failed to read template file: {}", filename))?; .context(format!("Failed to read template: {}", filename))?;
if !existing.exists() { if !existing.exists() {
will_create.push(filename); will_create.push(filename.clone());
continue; continue;
} }
let existing_content = fs::read(&existing) let existing_content = fs::read(&existing)
.context(format!("Failed to read project file: {}", filename))?; .context(format!("Failed to read project: {}", filename))?;
if existing_content == fresh_content { if existing_content == fresh_content {
up_to_date.push(filename); up_to_date.push(filename.clone());
} else { } else {
has_changes.push(filename); has_changes.push(filename.clone());
} }
} }
@@ -111,7 +158,11 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
if !will_create.is_empty() { if !will_create.is_empty() {
for f in &will_create { for f in &will_create {
println!(" {} {} (new)", "+".bright_green(), f.bright_white()); println!(
" {} {} (new)",
"+".bright_green(),
f.bright_white()
);
} }
} }
@@ -125,22 +176,90 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
} }
} }
// Report ignored files -- always, so the user knows
if !ignored.is_empty() {
println!();
println!(
"{} {} file(s) protected by .anvilignore:",
"skip".bright_black(),
ignored.len()
);
for (file, pattern) in &ignored {
if file == pattern {
println!(
" {} {} (ignored)",
"-".bright_black(),
file.bright_black()
);
} else {
println!(
" {} {} (matches: {})",
"-".bright_black(),
file.bright_black(),
pattern.bright_black()
);
}
}
if file_override.is_none() {
println!(
" {}",
"Override one: anvil refresh --force --file <path>"
.bright_black()
);
}
}
// Decide what to do // Decide what to do
if has_changes.is_empty() && will_create.is_empty() { if has_changes.is_empty() && will_create.is_empty() {
println!(); println!();
println!( println!(
"{}", "{}",
"All scripts are up to date. Nothing to do." "All managed files are up to date. Nothing to do."
.bright_green() .bright_green()
.bold() .bold()
); );
return Ok(()); return Ok(());
} }
// Always create missing files (they are not conflicting, just absent)
if !will_create.is_empty() {
for filename in &will_create {
let fresh = temp_dir.path().join(filename);
let dest = project_root.join(filename);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&fresh, &dest)
.context(format!("Failed to write: {}", filename))?;
}
// Make new shell scripts executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for filename in &will_create {
if filename.ends_with(".sh") {
let path = project_root.join(filename);
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&path, perms);
}
}
}
}
println!(
"{} Created {} missing file(s).",
"ok".green(),
will_create.len()
);
}
if !has_changes.is_empty() && !force { if !has_changes.is_empty() && !force {
println!(); println!();
println!( println!(
"{} {} script(s) differ from the latest Anvil templates.", "{} {} file(s) differ from the latest Anvil templates.",
"!".bright_yellow(), "!".bright_yellow(),
has_changes.len() has_changes.len()
); );
@@ -152,54 +271,120 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
println!(); println!();
println!( println!(
" {}", " {}",
"Only build scripts are replaced. Your .anvil.toml and source code are never touched." "Only managed files are replaced. Your source code \
and .anvilignore files are never touched."
.bright_black() .bright_black()
); );
return Ok(()); return Ok(());
} }
// Apply updates // Apply changed file updates (requires --force)
let files_to_write: Vec<&str> = if force { if !has_changes.is_empty() {
will_create.iter().chain(has_changes.iter()).copied().collect() for filename in &has_changes {
} else { let fresh = temp_dir.path().join(filename);
will_create.to_vec() let dest = project_root.join(filename);
}; if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
for filename in &files_to_write { }
let fresh = temp_dir.path().join(filename); fs::copy(&fresh, &dest)
let dest = project_root.join(filename); .context(format!("Failed to write: {}", filename))?;
// Ensure parent directory exists
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
} }
fs::copy(&fresh, &dest) // Make shell scripts executable on Unix
.context(format!("Failed to write: {}", filename))?; #[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
for filename in &has_changes {
if filename.ends_with(".sh") {
let path = project_root.join(filename);
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = fs::set_permissions(&path, perms);
}
}
}
}
println!(
"{} Updated {} file(s).",
"ok".green(),
has_changes.len()
);
} }
// Make shell scripts executable on Unix Ok(())
#[cfg(unix)] }
{
use std::os::unix::fs::PermissionsExt; /// Add a pattern to .anvilignore.
for filename in &files_to_write { pub fn add_ignore(project_dir: Option<&str>, pattern: &str) -> Result<()> {
if filename.ends_with(".sh") { let project_path = match project_dir {
let path = project_root.join(filename); Some(dir) => PathBuf::from(dir),
if let Ok(meta) = fs::metadata(&path) { None => std::env::current_dir()?,
let mut perms = meta.permissions(); };
perms.set_mode(0o755); let project_root = ProjectConfig::find_project_root(&project_path)?;
let _ = fs::set_permissions(&path, perms);
} // Check if already present
let anvilignore = AnvilIgnore::load(&project_root)?;
let normalized = pattern.replace('\\', "/");
if anvilignore.has_pattern(&normalized) {
println!(
"{} Pattern already in .anvilignore: {}",
"ok".green(),
normalized.bright_white()
);
return Ok(());
}
ignore::add_pattern(&project_root, pattern)?;
println!(
"{} Added to .anvilignore: {}",
"ok".green(),
normalized.bright_white().bold()
);
Ok(())
}
/// Remove a pattern from .anvilignore.
pub fn remove_ignore(
project_dir: Option<&str>,
pattern: &str,
) -> Result<()> {
let project_path = match project_dir {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()?,
};
let project_root = ProjectConfig::find_project_root(&project_path)?;
let removed = ignore::remove_pattern(&project_root, pattern)?;
let normalized = pattern.replace('\\', "/");
if removed {
println!(
"{} Removed from .anvilignore: {}",
"ok".green(),
normalized.bright_white().bold()
);
println!(
" {}",
"This file will now be updated by anvil refresh --force."
.bright_black()
);
} else {
println!(
"{} Pattern not found in .anvilignore: {}",
"!".bright_yellow(),
normalized.bright_white()
);
// Show current patterns
let anvilignore = AnvilIgnore::load(&project_root)?;
if !anvilignore.patterns().is_empty() {
println!(" Current patterns:");
for p in anvilignore.patterns() {
println!(" {}", p.bright_black());
} }
} }
} }
println!();
println!(
"{} Updated {} file(s).",
"ok".green(),
files_to_write.len()
);
Ok(()) Ok(())
} }

466
src/ignore.rs Normal file
View File

@@ -0,0 +1,466 @@
use std::fs;
use std::path::Path;
use anyhow::{Result, Context};
const IGNORE_FILENAME: &str = ".anvilignore";
/// A parsed .anvilignore file.
#[derive(Debug, Clone)]
pub struct AnvilIgnore {
/// Raw patterns from the file (comments and blanks stripped).
patterns: Vec<String>,
}
impl AnvilIgnore {
/// Load .anvilignore from a project directory.
/// Returns an empty ignore set if the file does not exist.
pub fn load(project_dir: &Path) -> Result<Self> {
let path = project_dir.join(IGNORE_FILENAME);
if !path.exists() {
return Ok(Self { patterns: vec![] });
}
let content = fs::read_to_string(&path)
.context("Failed to read .anvilignore")?;
Ok(Self::parse(&content))
}
/// Parse ignore patterns from text content.
pub fn parse(content: &str) -> Self {
let patterns = content
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| normalize_path(line))
.collect();
Self { patterns }
}
/// Check if a file path matches any ignore pattern.
/// Paths should use forward slashes (normalized).
pub fn is_ignored(&self, path: &str) -> bool {
let normalized = normalize_path(path);
self.patterns.iter().any(|pat| glob_match(pat, &normalized))
}
/// Return which pattern caused a path to be ignored (for UX reporting).
pub fn matching_pattern(&self, path: &str) -> Option<&str> {
let normalized = normalize_path(path);
self.patterns
.iter()
.find(|pat| glob_match(pat, &normalized))
.map(|s| s.as_str())
}
/// All patterns in the file.
pub fn patterns(&self) -> &[String] {
&self.patterns
}
/// Check if a pattern already exists.
pub fn has_pattern(&self, pattern: &str) -> bool {
let normalized = normalize_path(pattern);
self.patterns.iter().any(|p| *p == normalized)
}
}
/// Add a pattern to the .anvilignore file. Creates the file if needed.
pub fn add_pattern(project_dir: &Path, pattern: &str) -> Result<()> {
let path = project_dir.join(IGNORE_FILENAME);
let normalized = normalize_path(pattern);
// Load existing content or start fresh
let mut content = if path.exists() {
fs::read_to_string(&path)
.context("Failed to read .anvilignore")?
} else {
default_header()
};
// Check for duplicate
let ignore = AnvilIgnore::parse(&content);
if ignore.has_pattern(&normalized) {
return Ok(());
}
// Append
if !content.ends_with('\n') {
content.push('\n');
}
content.push_str(&normalized);
content.push('\n');
fs::write(&path, content)
.context("Failed to write .anvilignore")?;
Ok(())
}
/// Remove a pattern from the .anvilignore file.
/// Returns true if the pattern was found and removed.
pub fn remove_pattern(project_dir: &Path, pattern: &str) -> Result<bool> {
let path = project_dir.join(IGNORE_FILENAME);
if !path.exists() {
return Ok(false);
}
let content = fs::read_to_string(&path)
.context("Failed to read .anvilignore")?;
let normalized = normalize_path(pattern);
let mut found = false;
let mut lines: Vec<&str> = content.lines().collect();
lines.retain(|line| {
let trimmed = line.trim();
if normalize_path(trimmed) == normalized {
found = true;
false
} else {
true
}
});
if found {
let mut output = lines.join("\n");
if !output.ends_with('\n') {
output.push('\n');
}
fs::write(&path, output)
.context("Failed to write .anvilignore")?;
}
Ok(found)
}
/// Generate a default .anvilignore for a new project.
pub fn generate_default(project_dir: &Path, _template_name: &str) -> Result<()> {
let path = project_dir.join(IGNORE_FILENAME);
if path.exists() {
return Ok(());
}
let mut content = default_header();
// Student-owned test files are always protected
content.push_str("# Your test files -- anvil refresh will never touch these.\n");
content.push_str("test/test_unit.cpp\n");
content.push_str("test/test_system.cpp\n");
content.push_str("\n");
// Source code is always protected
content.push_str("# Your application code.\n");
content.push_str("lib/app/*\n");
content.push_str("\n");
// Sketch is protected
content.push_str("# Your Arduino sketch.\n");
content.push_str(&format!("{}/*.ino\n", "*/"));
content.push_str("\n");
// Config is protected
content.push_str("# Project configuration.\n");
content.push_str(".anvil.toml\n");
fs::write(&path, content)
.context("Failed to create .anvilignore")?;
Ok(())
}
fn default_header() -> String {
"# .anvilignore -- files that anvil refresh will never overwrite.\n\
# Patterns support * (any chars) and ? (single char) wildcards.\n\
# One pattern per line. Lines starting with # are comments.\n\
#\n\
# Manage with:\n\
# anvil refresh --ignore \"test/my_*.cpp\"\n\
# anvil refresh --unignore \"test/my_*.cpp\"\n\
#\n\n"
.to_string()
}
/// Normalize path separators to forward slashes for consistent matching.
fn normalize_path(path: &str) -> String {
path.replace('\\', "/")
}
/// Simple glob matching with * (any sequence) and ? (single char).
/// Both pattern and text should be normalized (forward slashes).
pub fn glob_match(pattern: &str, text: &str) -> bool {
glob_match_inner(pattern.as_bytes(), text.as_bytes())
}
fn glob_match_inner(pattern: &[u8], text: &[u8]) -> bool {
let mut pi = 0;
let mut ti = 0;
let mut star_pi = usize::MAX; // pattern index after last *
let mut star_ti = 0; // text index when last * was hit
while ti < text.len() {
if pi < pattern.len() && (pattern[pi] == b'?' || pattern[pi] == text[ti]) {
pi += 1;
ti += 1;
} else if pi < pattern.len() && pattern[pi] == b'*' {
star_pi = pi + 1;
star_ti = ti;
pi += 1;
} else if star_pi != usize::MAX {
// Backtrack: let * consume one more character
pi = star_pi;
star_ti += 1;
ti = star_ti;
} else {
return false;
}
}
// Consume trailing *s in pattern
while pi < pattern.len() && pattern[pi] == b'*' {
pi += 1;
}
pi == pattern.len()
}
// =========================================================================
// Tests
// =========================================================================
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// -- Glob matching ----------------------------------------------------
#[test]
fn test_glob_exact_match() {
assert!(glob_match("test/test_unit.cpp", "test/test_unit.cpp"));
}
#[test]
fn test_glob_no_match() {
assert!(!glob_match("test/test_unit.cpp", "test/test_system.cpp"));
}
#[test]
fn test_glob_star_suffix() {
assert!(glob_match("test/test_*", "test/test_unit.cpp"));
assert!(glob_match("test/test_*", "test/test_system.cpp"));
assert!(!glob_match("test/test_*", "test/run_tests.sh"));
}
#[test]
fn test_glob_star_prefix() {
assert!(glob_match("*.cpp", "test_unit.cpp"));
assert!(!glob_match("*.cpp", "test_unit.h"));
}
#[test]
fn test_glob_star_middle() {
assert!(glob_match("test/test_*.cpp", "test/test_unit.cpp"));
assert!(glob_match("test/test_*.cpp", "test/test_weather.cpp"));
assert!(!glob_match("test/test_*.cpp", "test/run_tests.sh"));
}
#[test]
fn test_glob_question_mark() {
assert!(glob_match("test?.cpp", "test1.cpp"));
assert!(glob_match("test?.cpp", "testA.cpp"));
assert!(!glob_match("test?.cpp", "test12.cpp"));
}
#[test]
fn test_glob_double_star() {
// * matches anything including path separators in our simple model
assert!(glob_match("lib/*", "lib/app/my_app.h"));
}
#[test]
fn test_glob_star_matches_empty() {
assert!(glob_match("test/*", "test/"));
}
#[test]
fn test_glob_multiple_stars() {
assert!(glob_match("*/test_*.cpp", "test/test_unit.cpp"));
}
#[test]
fn test_glob_backslash_normalized() {
// normalize_path converts \ to /
let norm = normalize_path("test\\test_unit.cpp");
assert!(glob_match("test/test_unit.cpp", &norm));
}
// -- AnvilIgnore parsing ----------------------------------------------
#[test]
fn test_parse_empty() {
let ignore = AnvilIgnore::parse("");
assert!(ignore.patterns().is_empty());
}
#[test]
fn test_parse_comments_and_blanks() {
let content = "# comment\n\n # another\n\ntest/test_unit.cpp\n";
let ignore = AnvilIgnore::parse(content);
assert_eq!(ignore.patterns().len(), 1);
assert_eq!(ignore.patterns()[0], "test/test_unit.cpp");
}
#[test]
fn test_parse_trims_whitespace() {
let content = " test/test_unit.cpp \n";
let ignore = AnvilIgnore::parse(content);
assert_eq!(ignore.patterns()[0], "test/test_unit.cpp");
}
#[test]
fn test_is_ignored_exact() {
let ignore = AnvilIgnore::parse("test/test_unit.cpp\n");
assert!(ignore.is_ignored("test/test_unit.cpp"));
assert!(!ignore.is_ignored("test/test_system.cpp"));
}
#[test]
fn test_is_ignored_glob() {
let ignore = AnvilIgnore::parse("test/test_*.cpp\n");
assert!(ignore.is_ignored("test/test_unit.cpp"));
assert!(ignore.is_ignored("test/test_system.cpp"));
assert!(!ignore.is_ignored("test/run_tests.sh"));
}
#[test]
fn test_is_ignored_normalizes_backslash() {
let ignore = AnvilIgnore::parse("test/test_unit.cpp\n");
assert!(ignore.is_ignored("test\\test_unit.cpp"));
}
#[test]
fn test_matching_pattern_returns_which() {
let ignore = AnvilIgnore::parse(
"test/test_unit.cpp\nlib/app/*\n",
);
assert_eq!(
ignore.matching_pattern("test/test_unit.cpp"),
Some("test/test_unit.cpp")
);
assert_eq!(
ignore.matching_pattern("lib/app/my_app.h"),
Some("lib/app/*")
);
assert_eq!(ignore.matching_pattern("build.sh"), None);
}
#[test]
fn test_has_pattern() {
let ignore = AnvilIgnore::parse("test/test_unit.cpp\n");
assert!(ignore.has_pattern("test/test_unit.cpp"));
assert!(!ignore.has_pattern("test/test_system.cpp"));
}
// -- File operations --------------------------------------------------
#[test]
fn test_load_missing_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.patterns().is_empty());
}
#[test]
fn test_load_existing_file() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert_eq!(ignore.patterns().len(), 1);
}
#[test]
fn test_add_pattern_creates_file() {
let tmp = TempDir::new().unwrap();
add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("test/test_unit.cpp"));
}
#[test]
fn test_add_pattern_no_duplicate() {
let tmp = TempDir::new().unwrap();
add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
add_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
let count = content.matches("test/test_unit.cpp").count();
assert_eq!(count, 1);
}
#[test]
fn test_add_pattern_normalizes_backslash() {
let tmp = TempDir::new().unwrap();
add_pattern(tmp.path(), "test\\test_unit.cpp").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("test/test_unit.cpp"));
}
#[test]
fn test_remove_pattern() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\ntest/test_system.cpp\n",
)
.unwrap();
let removed =
remove_pattern(tmp.path(), "test/test_unit.cpp").unwrap();
assert!(removed);
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(!content.contains("test/test_unit.cpp"));
assert!(content.contains("test/test_system.cpp"));
}
#[test]
fn test_remove_pattern_not_found() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\n",
)
.unwrap();
let removed =
remove_pattern(tmp.path(), "build.sh").unwrap();
assert!(!removed);
}
#[test]
fn test_generate_default_creates_file() {
let tmp = TempDir::new().unwrap();
generate_default(tmp.path(), "weather").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("test/test_unit.cpp"));
assert!(content.contains("test/test_system.cpp"));
assert!(content.contains("lib/app/*"));
assert!(content.contains(".anvil.toml"));
}
#[test]
fn test_generate_default_does_not_overwrite() {
let tmp = TempDir::new().unwrap();
fs::write(
tmp.path().join(".anvilignore"),
"my_custom_pattern\n",
)
.unwrap();
generate_default(tmp.path(), "basic").unwrap();
let content =
fs::read_to_string(tmp.path().join(".anvilignore")).unwrap();
assert!(content.contains("my_custom_pattern"));
assert!(!content.contains("test/test_unit.cpp"));
}
}

View File

@@ -3,4 +3,5 @@ pub mod commands;
pub mod project; pub mod project;
pub mod board; pub mod board;
pub mod templates; pub mod templates;
pub mod library; pub mod library;
pub mod ignore;

View File

@@ -81,9 +81,21 @@ enum Commands {
/// Path to project directory (defaults to current directory) /// Path to project directory (defaults to current directory)
dir: Option<String>, dir: Option<String>,
/// Overwrite scripts even if they have been modified /// Overwrite managed files even if they have been modified
#[arg(long)] #[arg(long)]
force: bool, force: bool,
/// Override .anvilignore for a specific file (use with --force)
#[arg(long, value_name = "PATH")]
file: Option<String>,
/// Add a pattern to .anvilignore (protect a file from refresh)
#[arg(long, value_name = "PATTERN", conflicts_with_all = ["unignore", "force"])]
ignore: Option<String>,
/// Remove a pattern from .anvilignore (allow refresh to update it)
#[arg(long, value_name = "PATTERN", conflicts_with_all = ["ignore", "force"])]
unignore: Option<String>,
}, },
/// Manage board profiles in .anvil.toml /// Manage board profiles in .anvil.toml
@@ -264,11 +276,24 @@ fn main() -> Result<()> {
commands::devices::scan_devices() commands::devices::scan_devices()
} }
} }
Commands::Refresh { dir, force } => { Commands::Refresh { dir, force, file, ignore, unignore } => {
commands::refresh::run_refresh( if let Some(pattern) = ignore {
dir.as_deref(), commands::refresh::add_ignore(
force, dir.as_deref(),
) &pattern,
)
} else if let Some(pattern) = unignore {
commands::refresh::remove_ignore(
dir.as_deref(),
&pattern,
)
} else {
commands::refresh::run_refresh(
dir.as_deref(),
force,
file.as_deref(),
)
}
} }
Commands::Board { name, add, remove, default, listall, id, baud, dir } => { Commands::Board { name, add, remove, default, listall, id, baud, dir } => {
if listall { if listall {

View File

@@ -25,6 +25,12 @@ pub struct ProjectConfig {
pub struct ProjectMeta { pub struct ProjectMeta {
pub name: String, pub name: String,
pub anvil_version: String, pub anvil_version: String,
#[serde(default = "default_template")]
pub template: String,
}
fn default_template() -> String {
"basic".to_string()
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
@@ -93,6 +99,7 @@ impl ProjectConfig {
project: ProjectMeta { project: ProjectMeta {
name: name.to_string(), name: name.to_string(),
anvil_version: ANVIL_VERSION.to_string(), anvil_version: ANVIL_VERSION.to_string(),
template: "basic".to_string(),
}, },
build: BuildConfig { build: BuildConfig {
default: "uno".to_string(), default: "uno".to_string(),

View File

@@ -1,3 +1,13 @@
/*
* test_system.cpp -- Your system tests go here.
*
* This file is YOURS. Anvil will never overwrite it.
* The weather station example tests are in test_weather.cpp.
*
* System tests use SimHal and Tmp36Sim to exercise real application
* logic against simulated hardware. See test_weather.cpp for examples.
*/
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include "mock_arduino.h" #include "mock_arduino.h"
@@ -6,114 +16,13 @@
#include "tmp36_sim.h" #include "tmp36_sim.h"
#include "{{PROJECT_NAME}}_app.h" #include "{{PROJECT_NAME}}_app.h"
// ============================================================================ // Example: add your own system tests below
// System Tests -- exercise WeatherApp with simulated sensor and hardware // TEST(MySystemTests, DescribeWhatItTests) {
// mock_arduino_reset();
// SimHal sim;
// Tmp36Sim sensor(25.0f, 0.5f);
// //
// These tests use SimHal (simulated GPIO, timing, serial) and Tmp36Sim // WeatherApp app(&sim, &sensor);
// (simulated analog sensor with noise). No mocking expectations -- we // app.begin();
// observe real behavior through SimHal's inspection API. // // ... your test logic ...
// ============================================================================ // }
class WeatherSystemTest : public ::testing::Test {
protected:
void SetUp() override {
mock_arduino_reset();
sim_.setMillis(0);
}
SimHal sim_;
Tmp36Sim sensor_{25.0f}; // 25 C base temperature
};
TEST_F(WeatherSystemTest, StartsAndPrintsToSerial) {
WeatherApp app(&sim_, &sensor_);
app.begin();
std::string output = sim_.serialOutput();
EXPECT_NE(output.find("WeatherApp started"), std::string::npos);
EXPECT_NE(output.find("Temperature:"), std::string::npos);
}
TEST_F(WeatherSystemTest, InitialReadingIsReasonable) {
Tmp36Sim exact_sensor(25.0f, 0.0f); // zero noise
WeatherApp app(&sim_, &exact_sensor);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 25.0f, 1.0f);
EXPECT_EQ(app.readCount(), 1);
}
TEST_F(WeatherSystemTest, ReadsAtTwoSecondIntervals) {
WeatherApp app(&sim_, &sensor_);
app.begin();
EXPECT_EQ(app.readCount(), 1);
// 1 second -- no new reading
sim_.advanceMillis(1000);
app.update();
EXPECT_EQ(app.readCount(), 1);
// 2 seconds -- new reading
sim_.advanceMillis(1000);
app.update();
EXPECT_EQ(app.readCount(), 2);
// 4 seconds -- another reading
sim_.advanceMillis(2000);
app.update();
EXPECT_EQ(app.readCount(), 3);
}
TEST_F(WeatherSystemTest, FiveMinuteRun) {
WeatherApp app(&sim_, &sensor_);
app.begin();
// Run 5 minutes at 100ms resolution
for (int i = 0; i < 3000; ++i) {
sim_.advanceMillis(100);
app.update();
}
// 5 minutes = 300 seconds / 2 second interval = 150 readings + 1 initial
EXPECT_EQ(app.readCount(), 151);
}
TEST_F(WeatherSystemTest, TemperatureChangeMidRun) {
Tmp36Sim sensor(20.0f, 0.0f); // start at 20 C, no noise
WeatherApp app(&sim_, &sensor);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 20.0f, 1.0f);
// Change temperature and wait for next reading
sensor.setBaseTemperature(35.0f);
sim_.advanceMillis(2000);
app.update();
EXPECT_NEAR(app.lastCelsius(), 35.0f, 1.0f);
}
TEST_F(WeatherSystemTest, SerialOutputContainsFahrenheit) {
Tmp36Sim exact_sensor(0.0f, 0.0f); // 0 C = 32 F
WeatherApp app(&sim_, &exact_sensor);
app.begin();
std::string output = sim_.serialOutput();
EXPECT_NE(output.find("32"), std::string::npos)
<< "Should contain 32 F for 0 C: " << output;
}
TEST_F(WeatherSystemTest, NoisyReadingsStayInRange) {
Tmp36Sim noisy_sensor(25.0f, 2.0f); // +/- 2 C noise
noisy_sensor.setSeed(42);
WeatherApp app(&sim_, &noisy_sensor);
for (int i = 0; i < 20; ++i) {
sim_.setMillis(i * 2000);
if (i == 0) app.begin(); else app.update();
float c = app.lastCelsius();
EXPECT_GE(c, 20.0f) << "Reading " << i << " too low: " << c;
EXPECT_LE(c, 30.0f) << "Reading " << i << " too high: " << c;
}
}

View File

@@ -1,3 +1,13 @@
/*
* test_unit.cpp -- Your unit tests go here.
*
* This file is YOURS. Anvil will never overwrite it.
* The weather station example tests are in test_weather.cpp.
*
* Unit tests use MockHal and Tmp36Mock to verify exact behavior
* without real hardware. See test_weather.cpp for examples.
*/
#include <gtest/gtest.h> #include <gtest/gtest.h>
#include <gmock/gmock.h> #include <gmock/gmock.h>
@@ -9,124 +19,12 @@
using ::testing::_; using ::testing::_;
using ::testing::AnyNumber; using ::testing::AnyNumber;
using ::testing::Return; using ::testing::Return;
using ::testing::HasSubstr;
// ============================================================================ // Example: add your own tests below
// Unit Tests -- verify WeatherApp behavior with mock sensor // TEST(MyTests, DescribeWhatItTests) {
// ============================================================================ // ::testing::NiceMock<MockHal> mock;
// Tmp36Mock sensor;
class WeatherUnitTest : public ::testing::Test { // sensor.setTemperature(25.0f);
protected: //
void SetUp() override { // // ... your test logic ...
ON_CALL(mock_, millis()).WillByDefault(Return(0)); // }
EXPECT_CALL(mock_, serialBegin(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrint(_)).Times(AnyNumber());
EXPECT_CALL(mock_, serialPrintln(_)).Times(AnyNumber());
EXPECT_CALL(mock_, millis()).Times(AnyNumber());
}
::testing::NiceMock<MockHal> mock_;
Tmp36Mock sensor_;
};
TEST_F(WeatherUnitTest, BeginPrintsStartupMessage) {
WeatherApp app(&mock_, &sensor_);
EXPECT_CALL(mock_, serialBegin(115200)).Times(1);
EXPECT_CALL(mock_, serialPrintln(HasSubstr("WeatherApp started"))).Times(1);
app.begin();
}
TEST_F(WeatherUnitTest, BeginTakesInitialReading) {
sensor_.setTemperature(25.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_EQ(app.readCount(), 1);
EXPECT_NEAR(app.lastCelsius(), 25.0f, 0.1f);
}
TEST_F(WeatherUnitTest, ReadsAfterInterval) {
sensor_.setTemperature(20.0f);
WeatherApp app(&mock_, &sensor_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
EXPECT_EQ(app.readCount(), 1);
// Not enough time yet
ON_CALL(mock_, millis()).WillByDefault(Return(1999));
app.update();
EXPECT_EQ(app.readCount(), 1);
// Now 2 seconds have passed
ON_CALL(mock_, millis()).WillByDefault(Return(2000));
app.update();
EXPECT_EQ(app.readCount(), 2);
}
TEST_F(WeatherUnitTest, DoesNotReadTooEarly) {
sensor_.setTemperature(22.0f);
WeatherApp app(&mock_, &sensor_);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
ON_CALL(mock_, millis()).WillByDefault(Return(1500));
app.update();
EXPECT_EQ(app.readCount(), 1);
}
TEST_F(WeatherUnitTest, CelsiusToFahrenheitConversion) {
sensor_.setTemperature(0.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 0.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 32.0f, 0.1f);
}
TEST_F(WeatherUnitTest, BoilingPoint) {
sensor_.setTemperature(100.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), 100.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 212.0f, 0.1f);
}
TEST_F(WeatherUnitTest, NegativeTemperature) {
sensor_.setTemperature(-10.0f);
WeatherApp app(&mock_, &sensor_);
app.begin();
EXPECT_NEAR(app.lastCelsius(), -10.0f, 0.1f);
EXPECT_NEAR(app.lastFahrenheit(), 14.0f, 0.1f);
}
TEST_F(WeatherUnitTest, PrintsTemperatureOnRead) {
sensor_.setTemperature(25.0f);
WeatherApp app(&mock_, &sensor_);
EXPECT_CALL(mock_, serialPrint(HasSubstr("Temperature: "))).Times(1);
EXPECT_CALL(mock_, serialPrintln(HasSubstr(" F)"))).Times(1);
app.begin();
}
TEST_F(WeatherUnitTest, MultipleReadingsTrackNewTemperature) {
WeatherApp app(&mock_, &sensor_);
sensor_.setTemperature(20.0f);
ON_CALL(mock_, millis()).WillByDefault(Return(0));
app.begin();
EXPECT_NEAR(app.lastCelsius(), 20.0f, 0.1f);
sensor_.setTemperature(30.0f);
ON_CALL(mock_, millis()).WillByDefault(Return(2000));
app.update();
EXPECT_NEAR(app.lastCelsius(), 30.0f, 0.1f);
EXPECT_EQ(app.readCount(), 2);
}

View File

@@ -0,0 +1,250 @@
/*
* test_weather.cpp -- Weather station example tests.
*
* THIS FILE IS MANAGED BY ANVIL and will be updated by `anvil refresh`.
* Do not edit -- put your own tests in test_unit.cpp and test_system.cpp.
*/
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "mock_arduino.h"
#include "hal.h"
#include "mock_hal.h"
#include "sim_hal.h"
#include "tmp36_mock.h"
#include "tmp36_sim.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);
}
// ============================================================================
// System Tests -- exercise WeatherApp with simulated sensor and hardware
// ============================================================================
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;
}
}

2121
tests/test_refresh.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
use anvil::commands; use anvil::commands;
use anvil::ignore::{self, AnvilIgnore};
use anvil::library; use anvil::library;
use anvil::project::config::ProjectConfig; use anvil::project::config::ProjectConfig;
use anvil::templates::{TemplateManager, TemplateContext}; use anvil::templates::{TemplateManager, TemplateContext};
@@ -174,25 +175,38 @@ fn test_weather_sketch_replaces_basic_sketch() {
} }
#[test] #[test]
fn test_weather_has_unit_tests() { 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 tmp = extract_weather("wx");
let test_path = tmp.path().join("test").join("test_unit.cpp"); let test_path = tmp.path().join("test").join("test_unit.cpp");
assert!(test_path.exists()); assert!(test_path.exists());
let content = fs::read_to_string(&test_path).unwrap(); let content = fs::read_to_string(&test_path).unwrap();
assert!(content.contains("Tmp36Mock")); // Should be a minimal starter, not the full weather tests
assert!(content.contains("WeatherUnitTest")); assert!(content.contains("Your unit tests go here"));
assert!(content.contains("wx_app.h")); assert!(!content.contains("WeatherUnitTest"));
} }
#[test] #[test]
fn test_weather_has_system_tests() { fn test_weather_has_student_system_starter() {
let tmp = extract_weather("wx"); let tmp = extract_weather("wx");
let test_path = tmp.path().join("test").join("test_system.cpp"); let test_path = tmp.path().join("test").join("test_system.cpp");
assert!(test_path.exists()); assert!(test_path.exists());
let content = fs::read_to_string(&test_path).unwrap(); let content = fs::read_to_string(&test_path).unwrap();
assert!(content.contains("Tmp36Sim")); assert!(content.contains("Your system tests go here"));
assert!(content.contains("WeatherSystemTest")); assert!(!content.contains("WeatherSystemTest"));
assert!(content.contains("SimHal"));
} }
#[test] #[test]
@@ -425,11 +439,12 @@ fn test_weather_full_flow() {
assert!(!app_content.contains("BlinkApp")); assert!(!app_content.contains("BlinkApp"));
// Test files present (weather-specific) // Test files present (weather-specific)
let unit_content = fs::read_to_string( let weather_content = fs::read_to_string(
tmp.path().join("test").join("test_unit.cpp"), tmp.path().join("test").join("test_weather.cpp"),
) )
.unwrap(); .unwrap();
assert!(unit_content.contains("Tmp36Mock")); assert!(weather_content.contains("Tmp36Mock"));
assert!(weather_content.contains("Tmp36Sim"));
// Sketch wires everything together // Sketch wires everything together
let ino_content = fs::read_to_string( let ino_content = fs::read_to_string(
@@ -596,7 +611,7 @@ fn test_template_system_tests_use_valid_sim_api() {
.unwrap(); .unwrap();
let test_source = fs::read_to_string( let test_source = fs::read_to_string(
tmp.path().join("test").join("test_system.cpp"), tmp.path().join("test").join("test_weather.cpp"),
) )
.unwrap(); .unwrap();
@@ -624,7 +639,7 @@ fn test_template_system_tests_use_valid_sim_api() {
if before_dot.contains("sensor") || before_dot.contains("sim") { if before_dot.contains("sensor") || before_dot.contains("sim") {
assert!( assert!(
methods.contains(&method_name.to_string()), methods.contains(&method_name.to_string()),
"test_system.cpp calls '{}.{}()' but '{}' \ "test_weather.cpp calls '{}.{}()' but '{}' \
is not in tmp36_sim.h.\n \ is not in tmp36_sim.h.\n \
Available methods: {:?}", Available methods: {:?}",
before_dot, before_dot,
@@ -653,7 +668,7 @@ fn test_template_unit_tests_use_valid_mock_api() {
.unwrap(); .unwrap();
let test_source = fs::read_to_string( let test_source = fs::read_to_string(
tmp.path().join("test").join("test_unit.cpp"), tmp.path().join("test").join("test_weather.cpp"),
) )
.unwrap(); .unwrap();
@@ -676,7 +691,7 @@ fn test_template_unit_tests_use_valid_mock_api() {
if before_dot.contains("sensor") { if before_dot.contains("sensor") {
assert!( assert!(
methods.contains(&method_name.to_string()), methods.contains(&method_name.to_string()),
"test_unit.cpp calls '{}.{}()' but '{}' \ "test_weather.cpp calls '{}.{}()' but '{}' \
is not in tmp36_mock.h.\n \ is not in tmp36_mock.h.\n \
Available methods: {:?}", Available methods: {:?}",
before_dot, before_dot,
@@ -708,7 +723,7 @@ fn test_template_sim_constructor_arg_count() {
count_constructor_params(&sim_header, "Tmp36Sim"); count_constructor_params(&sim_header, "Tmp36Sim");
let test_source = fs::read_to_string( let test_source = fs::read_to_string(
tmp.path().join("test").join("test_system.cpp"), tmp.path().join("test").join("test_weather.cpp"),
) )
.unwrap(); .unwrap();
@@ -735,7 +750,7 @@ fn test_template_sim_constructor_arg_count() {
}; };
assert!( assert!(
arg_count >= min_args && arg_count <= total_params, arg_count >= min_args && arg_count <= total_params,
"test_system.cpp line {}: Tmp36Sim() called with \ "test_weather.cpp line {}: Tmp36Sim() called with \
{} args, but constructor accepts {}-{} args.\n \ {} args, but constructor accepts {}-{} args.\n \
Line: {}", Line: {}",
line_num + 1, line_num + 1,
@@ -747,4 +762,370 @@ fn test_template_sim_constructor_arg_count() {
} }
} }
} }
}
// =========================================================================
// .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
);
}
} }