Down to one test failing
This commit is contained in:
@@ -185,6 +185,17 @@ pub fn create_project(
|
||||
TemplateManager::extract(template_name, &project_path, &context)?;
|
||||
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
|
||||
if let Some(meta) = TemplateManager::composed_meta(template_name) {
|
||||
// Install required libraries
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
use anyhow::{Result, Context};
|
||||
use colored::*;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
use crate::ignore::{self, AnvilIgnore};
|
||||
use crate::project::config::ProjectConfig;
|
||||
use crate::templates::{TemplateManager, TemplateContext};
|
||||
use crate::version::ANVIL_VERSION;
|
||||
|
||||
/// Files that anvil owns and can safely refresh.
|
||||
/// These are build/deploy infrastructure -- not user source code.
|
||||
const REFRESHABLE_FILES: &[&str] = &[
|
||||
"build.sh",
|
||||
"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",
|
||||
/// Files that are never refreshable, even if they appear in the template.
|
||||
/// .anvilignore itself is the user's protection config -- never overwrite it.
|
||||
const NEVER_REFRESH: &[&str] = &[
|
||||
".anvilignore",
|
||||
];
|
||||
|
||||
pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
|
||||
// Resolve project directory
|
||||
/// Recursively walk a directory and return all file paths relative to `base`.
|
||||
/// 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 {
|
||||
Some(dir) => PathBuf::from(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 config = ProjectConfig::load(&project_root)?;
|
||||
let anvilignore = AnvilIgnore::load(&project_root)?;
|
||||
|
||||
let template_name = &config.project.template;
|
||||
|
||||
println!(
|
||||
"Refreshing project: {}",
|
||||
config.project.name.bright_white().bold()
|
||||
);
|
||||
println!(
|
||||
"Template: {}",
|
||||
template_name.bright_cyan()
|
||||
);
|
||||
println!(
|
||||
"Project directory: {}",
|
||||
project_root.display().to_string().bright_black()
|
||||
);
|
||||
println!();
|
||||
|
||||
// Generate fresh copies of all refreshable files from the template
|
||||
let template_name = "basic";
|
||||
// Build template context for extraction
|
||||
let context = TemplateContext {
|
||||
project_name: config.project.name.clone(),
|
||||
anvil_version: ANVIL_VERSION.to_string(),
|
||||
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,
|
||||
};
|
||||
|
||||
// Extract template into a temp directory so we can compare
|
||||
let temp_dir = tempfile::tempdir()
|
||||
.context("Failed to create temp directory")?;
|
||||
// Extract fresh template + libraries into temp directory
|
||||
let temp_dir =
|
||||
tempfile::tempdir().context("Failed to create temp directory")?;
|
||||
TemplateManager::extract(template_name, temp_dir.path(), &context)?;
|
||||
|
||||
// Compare each refreshable file
|
||||
let mut up_to_date = Vec::new();
|
||||
let mut will_create = Vec::new();
|
||||
let mut has_changes = Vec::new();
|
||||
// Also extract library files into temp (for composed templates)
|
||||
for lib_name in config.libraries.keys() {
|
||||
let _ = crate::library::extract_library(lib_name, temp_dir.path());
|
||||
}
|
||||
|
||||
// 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 fresh = temp_dir.path().join(filename);
|
||||
|
||||
if !fresh.exists() {
|
||||
// Template doesn't produce this file (shouldn't happen)
|
||||
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)
|
||||
.context(format!("Failed to read template file: {}", filename))?;
|
||||
.context(format!("Failed to read template: {}", filename))?;
|
||||
|
||||
if !existing.exists() {
|
||||
will_create.push(filename);
|
||||
will_create.push(filename.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
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 {
|
||||
up_to_date.push(filename);
|
||||
up_to_date.push(filename.clone());
|
||||
} 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() {
|
||||
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
|
||||
if has_changes.is_empty() && will_create.is_empty() {
|
||||
println!();
|
||||
println!(
|
||||
"{}",
|
||||
"All scripts are up to date. Nothing to do."
|
||||
"All managed files are up to date. Nothing to do."
|
||||
.bright_green()
|
||||
.bold()
|
||||
);
|
||||
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 {
|
||||
println!();
|
||||
println!(
|
||||
"{} {} script(s) differ from the latest Anvil templates.",
|
||||
"{} {} file(s) differ from the latest Anvil templates.",
|
||||
"!".bright_yellow(),
|
||||
has_changes.len()
|
||||
);
|
||||
@@ -152,54 +271,120 @@ pub fn run_refresh(project_dir: Option<&str>, force: bool) -> Result<()> {
|
||||
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()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
let files_to_write: Vec<&str> = if force {
|
||||
will_create.iter().chain(has_changes.iter()).copied().collect()
|
||||
} else {
|
||||
will_create.to_vec()
|
||||
};
|
||||
|
||||
for filename in &files_to_write {
|
||||
let fresh = temp_dir.path().join(filename);
|
||||
let dest = project_root.join(filename);
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
// Apply changed file updates (requires --force)
|
||||
if !has_changes.is_empty() {
|
||||
for filename in &has_changes {
|
||||
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))?;
|
||||
}
|
||||
|
||||
fs::copy(&fresh, &dest)
|
||||
.context(format!("Failed to write: {}", filename))?;
|
||||
// Make shell scripts executable on Unix
|
||||
#[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
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
for filename in &files_to_write {
|
||||
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);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a pattern to .anvilignore.
|
||||
pub fn add_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)?;
|
||||
|
||||
// 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(())
|
||||
}
|
||||
466
src/ignore.rs
Normal file
466
src/ignore.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -3,4 +3,5 @@ pub mod commands;
|
||||
pub mod project;
|
||||
pub mod board;
|
||||
pub mod templates;
|
||||
pub mod library;
|
||||
pub mod library;
|
||||
pub mod ignore;
|
||||
37
src/main.rs
37
src/main.rs
@@ -81,9 +81,21 @@ enum Commands {
|
||||
/// Path to project directory (defaults to current directory)
|
||||
dir: Option<String>,
|
||||
|
||||
/// Overwrite scripts even if they have been modified
|
||||
/// Overwrite managed files even if they have been modified
|
||||
#[arg(long)]
|
||||
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
|
||||
@@ -264,11 +276,24 @@ fn main() -> Result<()> {
|
||||
commands::devices::scan_devices()
|
||||
}
|
||||
}
|
||||
Commands::Refresh { dir, force } => {
|
||||
commands::refresh::run_refresh(
|
||||
dir.as_deref(),
|
||||
force,
|
||||
)
|
||||
Commands::Refresh { dir, force, file, ignore, unignore } => {
|
||||
if let Some(pattern) = ignore {
|
||||
commands::refresh::add_ignore(
|
||||
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 } => {
|
||||
if listall {
|
||||
|
||||
@@ -25,6 +25,12 @@ pub struct ProjectConfig {
|
||||
pub struct ProjectMeta {
|
||||
pub name: String,
|
||||
pub anvil_version: String,
|
||||
#[serde(default = "default_template")]
|
||||
pub template: String,
|
||||
}
|
||||
|
||||
fn default_template() -> String {
|
||||
"basic".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -93,6 +99,7 @@ impl ProjectConfig {
|
||||
project: ProjectMeta {
|
||||
name: name.to_string(),
|
||||
anvil_version: ANVIL_VERSION.to_string(),
|
||||
template: "basic".to_string(),
|
||||
},
|
||||
build: BuildConfig {
|
||||
default: "uno".to_string(),
|
||||
|
||||
@@ -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 "mock_arduino.h"
|
||||
@@ -6,114 +16,13 @@
|
||||
#include "tmp36_sim.h"
|
||||
#include "{{PROJECT_NAME}}_app.h"
|
||||
|
||||
// ============================================================================
|
||||
// System Tests -- exercise WeatherApp with simulated sensor and hardware
|
||||
// Example: add your own system tests below
|
||||
// TEST(MySystemTests, DescribeWhatItTests) {
|
||||
// mock_arduino_reset();
|
||||
// SimHal sim;
|
||||
// Tmp36Sim sensor(25.0f, 0.5f);
|
||||
//
|
||||
// These tests use SimHal (simulated GPIO, timing, serial) and Tmp36Sim
|
||||
// (simulated analog sensor with noise). No mocking expectations -- we
|
||||
// observe real behavior through SimHal's inspection API.
|
||||
// ============================================================================
|
||||
|
||||
class WeatherSystemTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
mock_arduino_reset();
|
||||
sim_.setMillis(0);
|
||||
}
|
||||
|
||||
SimHal sim_;
|
||||
Tmp36Sim sensor_{25.0f}; // 25 C base temperature
|
||||
};
|
||||
|
||||
TEST_F(WeatherSystemTest, StartsAndPrintsToSerial) {
|
||||
WeatherApp app(&sim_, &sensor_);
|
||||
app.begin();
|
||||
|
||||
std::string output = sim_.serialOutput();
|
||||
EXPECT_NE(output.find("WeatherApp started"), std::string::npos);
|
||||
EXPECT_NE(output.find("Temperature:"), std::string::npos);
|
||||
}
|
||||
|
||||
TEST_F(WeatherSystemTest, InitialReadingIsReasonable) {
|
||||
Tmp36Sim exact_sensor(25.0f, 0.0f); // zero noise
|
||||
WeatherApp app(&sim_, &exact_sensor);
|
||||
app.begin();
|
||||
|
||||
EXPECT_NEAR(app.lastCelsius(), 25.0f, 1.0f);
|
||||
EXPECT_EQ(app.readCount(), 1);
|
||||
}
|
||||
|
||||
TEST_F(WeatherSystemTest, ReadsAtTwoSecondIntervals) {
|
||||
WeatherApp app(&sim_, &sensor_);
|
||||
app.begin();
|
||||
EXPECT_EQ(app.readCount(), 1);
|
||||
|
||||
// 1 second -- no new reading
|
||||
sim_.advanceMillis(1000);
|
||||
app.update();
|
||||
EXPECT_EQ(app.readCount(), 1);
|
||||
|
||||
// 2 seconds -- new reading
|
||||
sim_.advanceMillis(1000);
|
||||
app.update();
|
||||
EXPECT_EQ(app.readCount(), 2);
|
||||
|
||||
// 4 seconds -- another reading
|
||||
sim_.advanceMillis(2000);
|
||||
app.update();
|
||||
EXPECT_EQ(app.readCount(), 3);
|
||||
}
|
||||
|
||||
TEST_F(WeatherSystemTest, FiveMinuteRun) {
|
||||
WeatherApp app(&sim_, &sensor_);
|
||||
app.begin();
|
||||
|
||||
// Run 5 minutes at 100ms resolution
|
||||
for (int i = 0; i < 3000; ++i) {
|
||||
sim_.advanceMillis(100);
|
||||
app.update();
|
||||
}
|
||||
|
||||
// 5 minutes = 300 seconds / 2 second interval = 150 readings + 1 initial
|
||||
EXPECT_EQ(app.readCount(), 151);
|
||||
}
|
||||
|
||||
TEST_F(WeatherSystemTest, TemperatureChangeMidRun) {
|
||||
Tmp36Sim sensor(20.0f, 0.0f); // start at 20 C, no noise
|
||||
WeatherApp app(&sim_, &sensor);
|
||||
app.begin();
|
||||
|
||||
EXPECT_NEAR(app.lastCelsius(), 20.0f, 1.0f);
|
||||
|
||||
// Change temperature and wait for next reading
|
||||
sensor.setBaseTemperature(35.0f);
|
||||
sim_.advanceMillis(2000);
|
||||
app.update();
|
||||
|
||||
EXPECT_NEAR(app.lastCelsius(), 35.0f, 1.0f);
|
||||
}
|
||||
|
||||
TEST_F(WeatherSystemTest, SerialOutputContainsFahrenheit) {
|
||||
Tmp36Sim exact_sensor(0.0f, 0.0f); // 0 C = 32 F
|
||||
WeatherApp app(&sim_, &exact_sensor);
|
||||
app.begin();
|
||||
|
||||
std::string output = sim_.serialOutput();
|
||||
EXPECT_NE(output.find("32"), std::string::npos)
|
||||
<< "Should contain 32 F for 0 C: " << output;
|
||||
}
|
||||
|
||||
TEST_F(WeatherSystemTest, NoisyReadingsStayInRange) {
|
||||
Tmp36Sim noisy_sensor(25.0f, 2.0f); // +/- 2 C noise
|
||||
noisy_sensor.setSeed(42);
|
||||
WeatherApp app(&sim_, &noisy_sensor);
|
||||
|
||||
for (int i = 0; i < 20; ++i) {
|
||||
sim_.setMillis(i * 2000);
|
||||
if (i == 0) app.begin(); else app.update();
|
||||
|
||||
float c = app.lastCelsius();
|
||||
EXPECT_GE(c, 20.0f) << "Reading " << i << " too low: " << c;
|
||||
EXPECT_LE(c, 30.0f) << "Reading " << i << " too high: " << c;
|
||||
}
|
||||
}
|
||||
// WeatherApp app(&sim, &sensor);
|
||||
// app.begin();
|
||||
// // ... your test logic ...
|
||||
// }
|
||||
|
||||
@@ -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 <gmock/gmock.h>
|
||||
|
||||
@@ -9,124 +19,12 @@
|
||||
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);
|
||||
}
|
||||
// Example: add your own tests below
|
||||
// TEST(MyTests, DescribeWhatItTests) {
|
||||
// ::testing::NiceMock<MockHal> mock;
|
||||
// Tmp36Mock sensor;
|
||||
// sensor.setTemperature(25.0f);
|
||||
//
|
||||
// // ... your test logic ...
|
||||
// }
|
||||
|
||||
250
templates/weather/test/test_weather.cpp.tmpl
Normal file
250
templates/weather/test/test_weather.cpp.tmpl
Normal 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
2121
tests/test_refresh.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
use anvil::commands;
|
||||
use anvil::ignore::{self, AnvilIgnore};
|
||||
use anvil::library;
|
||||
use anvil::project::config::ProjectConfig;
|
||||
use anvil::templates::{TemplateManager, TemplateContext};
|
||||
@@ -174,25 +175,38 @@ fn test_weather_sketch_replaces_basic_sketch() {
|
||||
}
|
||||
|
||||
#[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 test_path = tmp.path().join("test").join("test_unit.cpp");
|
||||
assert!(test_path.exists());
|
||||
let content = fs::read_to_string(&test_path).unwrap();
|
||||
assert!(content.contains("Tmp36Mock"));
|
||||
assert!(content.contains("WeatherUnitTest"));
|
||||
assert!(content.contains("wx_app.h"));
|
||||
// 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_system_tests() {
|
||||
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("Tmp36Sim"));
|
||||
assert!(content.contains("WeatherSystemTest"));
|
||||
assert!(content.contains("SimHal"));
|
||||
assert!(content.contains("Your system tests go here"));
|
||||
assert!(!content.contains("WeatherSystemTest"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -425,11 +439,12 @@ fn test_weather_full_flow() {
|
||||
assert!(!app_content.contains("BlinkApp"));
|
||||
|
||||
// Test files present (weather-specific)
|
||||
let unit_content = fs::read_to_string(
|
||||
tmp.path().join("test").join("test_unit.cpp"),
|
||||
let weather_content = fs::read_to_string(
|
||||
tmp.path().join("test").join("test_weather.cpp"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(unit_content.contains("Tmp36Mock"));
|
||||
assert!(weather_content.contains("Tmp36Mock"));
|
||||
assert!(weather_content.contains("Tmp36Sim"));
|
||||
|
||||
// Sketch wires everything together
|
||||
let ino_content = fs::read_to_string(
|
||||
@@ -596,7 +611,7 @@ fn test_template_system_tests_use_valid_sim_api() {
|
||||
.unwrap();
|
||||
|
||||
let test_source = fs::read_to_string(
|
||||
tmp.path().join("test").join("test_system.cpp"),
|
||||
tmp.path().join("test").join("test_weather.cpp"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -624,7 +639,7 @@ fn test_template_system_tests_use_valid_sim_api() {
|
||||
if before_dot.contains("sensor") || before_dot.contains("sim") {
|
||||
assert!(
|
||||
methods.contains(&method_name.to_string()),
|
||||
"test_system.cpp calls '{}.{}()' but '{}' \
|
||||
"test_weather.cpp calls '{}.{}()' but '{}' \
|
||||
is not in tmp36_sim.h.\n \
|
||||
Available methods: {:?}",
|
||||
before_dot,
|
||||
@@ -653,7 +668,7 @@ fn test_template_unit_tests_use_valid_mock_api() {
|
||||
.unwrap();
|
||||
|
||||
let test_source = fs::read_to_string(
|
||||
tmp.path().join("test").join("test_unit.cpp"),
|
||||
tmp.path().join("test").join("test_weather.cpp"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -676,7 +691,7 @@ fn test_template_unit_tests_use_valid_mock_api() {
|
||||
if before_dot.contains("sensor") {
|
||||
assert!(
|
||||
methods.contains(&method_name.to_string()),
|
||||
"test_unit.cpp calls '{}.{}()' but '{}' \
|
||||
"test_weather.cpp calls '{}.{}()' but '{}' \
|
||||
is not in tmp36_mock.h.\n \
|
||||
Available methods: {:?}",
|
||||
before_dot,
|
||||
@@ -708,7 +723,7 @@ fn test_template_sim_constructor_arg_count() {
|
||||
count_constructor_params(&sim_header, "Tmp36Sim");
|
||||
|
||||
let test_source = fs::read_to_string(
|
||||
tmp.path().join("test").join("test_system.cpp"),
|
||||
tmp.path().join("test").join("test_weather.cpp"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -735,7 +750,7 @@ fn test_template_sim_constructor_arg_count() {
|
||||
};
|
||||
assert!(
|
||||
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 \
|
||||
Line: {}",
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user