Files
anvil/tests/test_refresh.rs
2026-02-21 21:33:27 -06:00

2121 lines
66 KiB
Rust

/// Integration tests for `anvil refresh` and `.anvilignore`.
///
/// Tests the full lifecycle: create project -> modify files -> refresh ->
/// verify ignore/update behavior across exact names, glob patterns,
/// --force, --file overrides, --ignore/--unignore management.
use anvil::commands;
use anvil::ignore::{self, AnvilIgnore};
use anvil::project::config::ProjectConfig;
use anvil::templates::{TemplateManager, TemplateContext};
use std::fs;
use tempfile::TempDir;
// =========================================================================
// Helpers
// =========================================================================
/// Create a fully-wired weather project (extract + config + libs + pins + .anvilignore).
fn make_weather(name: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: name.to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("weather", tmp.path(), &ctx).unwrap();
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.project.template = "weather".to_string();
config.save(tmp.path()).unwrap();
ignore::generate_default(tmp.path(), "weather").unwrap();
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
}
/// Create a basic project with .anvilignore.
fn make_basic(name: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
let ctx = TemplateContext {
project_name: name.to_string(),
anvil_version: "1.0.0".to_string(),
board_name: "uno".to_string(),
fqbn: "arduino:avr:uno".to_string(),
baud: 115200,
};
TemplateManager::extract("basic", tmp.path(), &ctx).unwrap();
ignore::generate_default(tmp.path(), "basic").unwrap();
tmp
}
/// Shorthand for run_refresh with path string conversion.
fn refresh(dir: &std::path::Path, force: bool, file: Option<&str>) {
commands::refresh::run_refresh(
Some(dir.to_str().unwrap()),
force,
file,
)
.unwrap();
}
/// Shorthand for add_ignore.
fn add_ignore(dir: &std::path::Path, pattern: &str) {
commands::refresh::add_ignore(
Some(dir.to_str().unwrap()),
pattern,
)
.unwrap();
}
/// Shorthand for remove_ignore.
fn remove_ignore(dir: &std::path::Path, pattern: &str) {
commands::refresh::remove_ignore(
Some(dir.to_str().unwrap()),
pattern,
)
.unwrap();
}
/// Tamper with a file by overwriting its contents.
fn tamper(dir: &std::path::Path, rel: &str, content: &str) {
let path = dir.join(rel);
fs::write(&path, content).unwrap();
}
/// Read a project file.
fn read(dir: &std::path::Path, rel: &str) -> String {
fs::read_to_string(dir.join(rel)).unwrap()
}
/// Check if file contains a substring.
fn contains(dir: &std::path::Path, rel: &str, needle: &str) -> bool {
read(dir, rel).contains(needle)
}
// =========================================================================
// Refresh basics -- managed files updated, user files untouched
// =========================================================================
#[test]
fn test_fresh_project_nothing_to_refresh() {
// A freshly created project should have all managed files identical
// to the template. Refresh should find nothing to do.
let tmp = make_weather("wx");
// No panic, no error
refresh(tmp.path(), false, None);
}
#[test]
fn test_tampered_managed_script_detected_without_force() {
let tmp = make_weather("wx");
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n");
// Without --force, run_refresh reports but does not overwrite
refresh(tmp.path(), false, None);
assert!(
contains(tmp.path(), "build.sh", "# tampered"),
"Without --force, tampered file should remain"
);
}
#[test]
fn test_force_restores_tampered_managed_script() {
let tmp = make_weather("wx");
let original = read(tmp.path(), "build.sh");
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n");
refresh(tmp.path(), true, None);
let after = read(tmp.path(), "build.sh");
assert_eq!(after, original, "Force should restore build.sh");
}
#[test]
fn test_force_restores_tampered_mock_header() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/mocks/mock_hal.h", "// tampered\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"),
"Force should restore mock_hal.h"
);
}
#[test]
fn test_force_restores_tampered_cmake() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/CMakeLists.txt", "# tampered\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/CMakeLists.txt", "cmake_minimum_required"),
"Force should restore CMakeLists.txt"
);
}
#[test]
fn test_force_restores_tampered_hal() {
let tmp = make_weather("wx");
tamper(tmp.path(), "lib/hal/hal.h", "// tampered\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "lib/hal/hal.h", "class Hal"),
"Force should restore hal.h"
);
}
// =========================================================================
// Ignored file survives --force
// =========================================================================
#[test]
fn test_ignored_test_unit_survives_force() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/test_unit.cpp", "// my custom tests\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// my custom tests\n",
"Ignored test_unit.cpp must survive --force"
);
}
#[test]
fn test_ignored_test_system_survives_force() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/test_system.cpp", "// my system tests\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_system.cpp"),
"// my system tests\n",
"Ignored test_system.cpp must survive --force"
);
}
#[test]
fn test_ignored_app_code_survives_force() {
let tmp = make_weather("wx");
tamper(tmp.path(), "lib/app/wx_app.h", "// my custom app\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "lib/app/wx_app.h"),
"// my custom app\n",
"Ignored app code must survive --force"
);
}
#[test]
fn test_ignored_config_survives_force() {
let tmp = make_weather("wx");
let config_before = read(tmp.path(), ".anvil.toml");
// Append a comment to config
let modified = format!("{}# user addition\n", config_before);
tamper(tmp.path(), ".anvil.toml", &modified);
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), ".anvil.toml", "# user addition"),
".anvil.toml is ignored, user changes must survive"
);
}
// =========================================================================
// Managed template example test (test_weather.cpp) IS refreshed
// =========================================================================
#[test]
fn test_managed_template_test_refreshed_by_force() {
let tmp = make_weather("wx");
tamper(
tmp.path(),
"test/test_weather.cpp",
"// tampered\n",
);
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"),
"Managed test_weather.cpp should be restored by --force"
);
assert!(
!contains(tmp.path(), "test/test_weather.cpp", "// tampered"),
"Tampered content should be gone"
);
}
// =========================================================================
// Library driver headers are managed and refreshed
// =========================================================================
#[test]
fn test_library_header_refreshed_by_force() {
let tmp = make_weather("wx");
tamper(
tmp.path(),
"lib/drivers/tmp36/tmp36.h",
"// tampered\n",
);
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "lib/drivers/tmp36/tmp36.h", "TempSensor"),
"Driver header should be restored"
);
}
#[test]
fn test_library_sim_header_refreshed_by_force() {
let tmp = make_weather("wx");
tamper(
tmp.path(),
"lib/drivers/tmp36/tmp36_sim.h",
"// tampered\n",
);
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h", "Tmp36Sim"),
"Sim header should be restored"
);
}
#[test]
fn test_library_mock_header_refreshed_by_force() {
let tmp = make_weather("wx");
tamper(
tmp.path(),
"lib/drivers/tmp36/tmp36_mock.h",
"// tampered\n",
);
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h", "Tmp36Mock"),
"Mock header should be restored"
);
}
#[test]
fn test_library_test_file_refreshed_by_force() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/test_tmp36.cpp", "// tampered\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_tmp36.cpp", "Tmp36"),
"Library test file should be restored"
);
}
// =========================================================================
// --file override: force-update one specific ignored file
// =========================================================================
#[test]
fn test_file_override_restores_ignored_file() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/test_unit.cpp", "// my custom code\n");
// Without --file, it stays
refresh(tmp.path(), true, None);
assert_eq!(read(tmp.path(), "test/test_unit.cpp"), "// my custom code\n");
// With --file, it gets restored
refresh(tmp.path(), true, Some("test/test_unit.cpp"));
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
"File override should restore test_unit.cpp to template"
);
}
#[test]
fn test_file_override_only_affects_specified_file() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/test_unit.cpp", "// custom unit\n");
tamper(tmp.path(), "test/test_system.cpp", "// custom system\n");
// Override only test_unit.cpp
refresh(tmp.path(), true, Some("test/test_unit.cpp"));
// test_unit.cpp restored
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
);
// test_system.cpp still has custom content (still ignored)
assert_eq!(
read(tmp.path(), "test/test_system.cpp"),
"// custom system\n",
"Other ignored files should remain untouched"
);
}
#[test]
fn test_file_override_with_backslash_path() {
let tmp = make_weather("wx");
tamper(tmp.path(), "test/test_unit.cpp", "// custom\n");
// Windows-style path should work
refresh(tmp.path(), true, Some("test\\test_unit.cpp"));
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
"Backslash path should be normalized"
);
}
#[test]
fn test_file_override_for_app_code() {
let tmp = make_weather("wx");
tamper(tmp.path(), "lib/app/wx_app.h", "// my custom app\n");
// Normally ignored via lib/app/*
refresh(tmp.path(), true, None);
assert_eq!(read(tmp.path(), "lib/app/wx_app.h"), "// my custom app\n");
// Force override for this specific file
refresh(tmp.path(), true, Some("lib/app/wx_app.h"));
assert!(
contains(tmp.path(), "lib/app/wx_app.h", "WeatherApp"),
"File override should restore app code"
);
}
// =========================================================================
// Full lifecycle: ignore -> modify -> force -> still protected ->
// unignore -> force -> restored
// =========================================================================
#[test]
fn test_lifecycle_ignore_modify_force_unignore_force() {
let tmp = make_weather("wx");
let original = read(tmp.path(), "test/test_unit.cpp");
// Step 1: test_unit.cpp is already ignored by default
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("test/test_unit.cpp"));
// Step 2: Modify it
tamper(tmp.path(), "test/test_unit.cpp", "// student work\n");
// Step 3: Force refresh -- file is protected
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// student work\n",
"Protected file must survive force"
);
// Step 4: Unignore it
remove_ignore(tmp.path(), "test/test_unit.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(!ignore.is_ignored("test/test_unit.cpp"));
// Step 5: Force refresh -- now it gets restored
refresh(tmp.path(), true, None);
let after = read(tmp.path(), "test/test_unit.cpp");
assert_eq!(
after, original,
"After unignore + force, file should be restored to template"
);
}
#[test]
fn test_lifecycle_add_custom_ignore_then_remove() {
let tmp = make_weather("wx");
// build.sh is normally managed (not ignored)
let original = read(tmp.path(), "build.sh");
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# my custom build\n");
// Force refresh restores it
refresh(tmp.path(), true, None);
assert_eq!(read(tmp.path(), "build.sh"), original);
// Now add build.sh to .anvilignore
add_ignore(tmp.path(), "build.sh");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("build.sh"));
// Tamper again
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# my custom build\n");
// Force refresh -- now protected
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "build.sh", "# my custom build"),
"After adding to ignore, build.sh should be protected"
);
// Remove from ignore
remove_ignore(tmp.path(), "build.sh");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(!ignore.is_ignored("build.sh"));
// Force refresh -- restored
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "build.sh"),
original,
"After removing from ignore, build.sh should be restored"
);
}
// =========================================================================
// Glob pattern protection
// =========================================================================
#[test]
fn test_glob_pattern_protects_multiple_files() {
let tmp = make_weather("wx");
// Add a glob pattern to protect all mock files
add_ignore(tmp.path(), "test/mocks/mock_*.h");
// Tamper with mock files
tamper(tmp.path(), "test/mocks/mock_hal.h", "// custom mock\n");
tamper(tmp.path(), "test/mocks/mock_arduino.h", "// custom arduino\n");
// Force refresh -- both protected by glob
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/mocks/mock_hal.h"),
"// custom mock\n",
"mock_hal.h should be protected by test/mocks/mock_*.h"
);
assert_eq!(
read(tmp.path(), "test/mocks/mock_arduino.h"),
"// custom arduino\n",
"mock_arduino.h should be protected by test/mocks/mock_*.h"
);
// sim_hal.h is NOT matched by mock_*.h, so it should be restored
tamper(tmp.path(), "test/mocks/sim_hal.h", "// tampered sim\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/mocks/sim_hal.h", "SimHal"),
"sim_hal.h does not match mock_*.h pattern, should be restored"
);
}
#[test]
fn test_glob_pattern_file_override_punches_through() {
let tmp = make_weather("wx");
// Add glob pattern
add_ignore(tmp.path(), "test/mocks/mock_*.h");
tamper(tmp.path(), "test/mocks/mock_hal.h", "// custom\n");
// Force with glob in place -- protected
refresh(tmp.path(), true, None);
assert_eq!(read(tmp.path(), "test/mocks/mock_hal.h"), "// custom\n");
// File override punches through the glob
refresh(tmp.path(), true, Some("test/mocks/mock_hal.h"));
assert!(
contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"),
"--file should override glob pattern protection"
);
}
#[test]
fn test_glob_star_cpp_protects_all_test_files() {
let tmp = make_weather("wx");
// Nuclear option: protect ALL .cpp files in test/
add_ignore(tmp.path(), "test/*.cpp");
// Tamper with everything
tamper(tmp.path(), "test/test_weather.cpp", "// custom weather\n");
tamper(tmp.path(), "test/test_tmp36.cpp", "// custom tmp36\n");
// Force refresh -- all protected
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
"// custom weather\n",
);
assert_eq!(
read(tmp.path(), "test/test_tmp36.cpp"),
"// custom tmp36\n",
);
// But scripts are NOT .cpp, so they get refreshed
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n");
refresh(tmp.path(), true, None);
assert!(
!contains(tmp.path(), "build.sh", "# tampered"),
"build.sh is not .cpp, should be refreshed"
);
}
#[test]
fn test_question_mark_wildcard_in_pattern() {
let tmp = make_weather("wx");
// Protect files matching test_???36.cpp (test_tmp36.cpp matches)
add_ignore(tmp.path(), "test/test_???36.cpp");
tamper(tmp.path(), "test/test_tmp36.cpp", "// custom\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_tmp36.cpp"),
"// custom\n",
"? wildcard should match single char"
);
}
// =========================================================================
// Pattern management via add_ignore / remove_ignore
// =========================================================================
#[test]
fn test_add_ignore_creates_file_if_missing() {
let tmp = make_basic("bp");
fs::remove_file(tmp.path().join(".anvilignore")).ok();
assert!(!tmp.path().join(".anvilignore").exists());
add_ignore(tmp.path(), "custom.txt");
assert!(tmp.path().join(".anvilignore").exists());
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.has_pattern("custom.txt"));
}
#[test]
fn test_add_ignore_idempotent() {
let tmp = make_weather("wx");
add_ignore(tmp.path(), "test/test_unit.cpp");
add_ignore(tmp.path(), "test/test_unit.cpp");
let content = read(tmp.path(), ".anvilignore");
let count = content
.lines()
.filter(|l| l.trim() == "test/test_unit.cpp")
.count();
assert_eq!(count, 1, "Should not duplicate pattern");
}
#[test]
fn test_add_ignore_normalizes_backslash() {
let tmp = make_weather("wx");
add_ignore(tmp.path(), "test\\custom.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.has_pattern("test/custom.cpp"));
assert!(ignore.is_ignored("test/custom.cpp"));
assert!(ignore.is_ignored("test\\custom.cpp"));
}
#[test]
fn test_remove_ignore_that_exists() {
let tmp = make_weather("wx");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.has_pattern("test/test_unit.cpp"));
remove_ignore(tmp.path(), "test/test_unit.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(!ignore.has_pattern("test/test_unit.cpp"));
}
#[test]
fn test_remove_ignore_preserves_other_patterns() {
let tmp = make_weather("wx");
remove_ignore(tmp.path(), "test/test_unit.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(!ignore.has_pattern("test/test_unit.cpp"));
// Other defaults should remain
assert!(ignore.has_pattern("test/test_system.cpp"));
assert!(ignore.has_pattern(".anvil.toml"));
}
#[test]
fn test_remove_nonexistent_pattern_does_not_error() {
let tmp = make_weather("wx");
// Should not panic or error
remove_ignore(tmp.path(), "does/not/exist.txt");
}
#[test]
fn test_add_then_remove_glob_pattern() {
let tmp = make_weather("wx");
add_ignore(tmp.path(), "test/my_*.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("test/my_custom.cpp"));
remove_ignore(tmp.path(), "test/my_*.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(!ignore.is_ignored("test/my_custom.cpp"));
}
// =========================================================================
// No .anvilignore -- everything managed
// =========================================================================
#[test]
fn test_no_anvilignore_all_files_managed() {
let tmp = make_weather("wx");
// Delete .anvilignore
fs::remove_file(tmp.path().join(".anvilignore")).unwrap();
// Tamper with a normally-ignored file
tamper(tmp.path(), "test/test_unit.cpp", "// custom\n");
// Force refresh -- with no .anvilignore, nothing is protected
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
"Without .anvilignore, all managed files should be refreshed"
);
}
// =========================================================================
// .anvilignore edge cases
// =========================================================================
#[test]
fn test_anvilignore_comments_and_blanks_ignored() {
let tmp = make_basic("bp");
fs::write(
tmp.path().join(".anvilignore"),
"# This is a comment\n\
\n\
# Another comment\n\
\n\
build.sh\n\
\n\
# trailing comment\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert_eq!(ignore.patterns().len(), 1);
assert!(ignore.is_ignored("build.sh"));
assert!(!ignore.is_ignored("# This is a comment"));
}
#[test]
fn test_anvilignore_whitespace_trimmed() {
let tmp = make_basic("bp");
fs::write(
tmp.path().join(".anvilignore"),
" build.sh \n test/test_unit.cpp\t\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("build.sh"));
assert!(ignore.is_ignored("test/test_unit.cpp"));
}
#[test]
fn test_anvilignore_backslash_in_pattern_normalized() {
let tmp = make_basic("bp");
fs::write(
tmp.path().join(".anvilignore"),
"test\\test_unit.cpp\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("test/test_unit.cpp"));
assert!(ignore.is_ignored("test\\test_unit.cpp"));
}
#[test]
fn test_anvilignore_empty_file_protects_nothing() {
let tmp = make_weather("wx");
fs::write(tmp.path().join(".anvilignore"), "").unwrap();
tamper(tmp.path(), "test/test_unit.cpp", "// custom\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
"Empty .anvilignore protects nothing"
);
}
// =========================================================================
// matching_pattern -- which rule matched (UX reporting)
// =========================================================================
#[test]
fn test_matching_pattern_exact_name() {
let tmp = make_weather("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_glob() {
let tmp = make_weather("wx");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert_eq!(
ignore.matching_pattern("lib/app/wx_app.h"),
Some("lib/app/*"),
);
}
#[test]
fn test_matching_pattern_none_for_managed() {
let tmp = make_weather("wx");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert_eq!(ignore.matching_pattern("build.sh"), None);
assert_eq!(ignore.matching_pattern("test/mocks/mock_hal.h"), None);
}
#[test]
fn test_matching_pattern_first_pattern_wins() {
let tmp = make_basic("bp");
fs::write(
tmp.path().join(".anvilignore"),
"test/test_unit.cpp\ntest/test_*.cpp\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
// Exact match comes first in the file, so it wins
assert_eq!(
ignore.matching_pattern("test/test_unit.cpp"),
Some("test/test_unit.cpp"),
);
// Only matches the glob
assert_eq!(
ignore.matching_pattern("test/test_system.cpp"),
Some("test/test_*.cpp"),
);
}
// =========================================================================
// Multiple patterns stacking
// =========================================================================
#[test]
fn test_multiple_exact_patterns() {
let tmp = make_basic("bp");
fs::write(
tmp.path().join(".anvilignore"),
"build.sh\nupload.sh\nmonitor.sh\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("build.sh"));
assert!(ignore.is_ignored("upload.sh"));
assert!(ignore.is_ignored("monitor.sh"));
assert!(!ignore.is_ignored("build.bat"));
}
#[test]
fn test_overlapping_exact_and_glob() {
let tmp = make_basic("bp");
fs::write(
tmp.path().join(".anvilignore"),
"build.sh\n*.sh\n",
)
.unwrap();
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("build.sh")); // matched by both
assert!(ignore.is_ignored("upload.sh")); // matched by glob
assert!(!ignore.is_ignored("build.bat")); // not .sh
}
// =========================================================================
// Config template field
// =========================================================================
#[test]
fn test_basic_project_template_defaults_to_basic() {
let tmp = make_basic("bp");
let config = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config.project.template, "basic");
}
#[test]
fn test_weather_project_template_is_weather() {
let tmp = make_weather("wx");
let config = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(config.project.template, "weather");
}
#[test]
fn test_template_field_survives_save_reload() {
let tmp = make_weather("wx");
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.project.template = "custom".to_string();
config.save(tmp.path()).unwrap();
let reloaded = ProjectConfig::load(tmp.path()).unwrap();
assert_eq!(reloaded.project.template, "custom");
}
// =========================================================================
// Multi-step scenario: full student workflow
// =========================================================================
#[test]
fn test_student_workflow_scenario() {
// Simulate a student's journey:
// 1. Create project
// 2. Write custom tests
// 3. Anvil upgrades, student runs refresh
// 4. Student's tests survive
// 5. Student wants to reset one file
// 6. Student adds protection for a custom helper
// Step 1: Create project
let tmp = make_weather("wx");
// Step 2: Student writes custom test code
tamper(
tmp.path(),
"test/test_unit.cpp",
"#include <gtest/gtest.h>\n\
TEST(MyTemp, ReadsCorrectly) {\n\
\x20 // student's real work\n\
\x20 EXPECT_TRUE(true);\n\
}\n",
);
tamper(
tmp.path(),
"test/test_system.cpp",
"#include <gtest/gtest.h>\n\
TEST(MySys, RunsForAnHour) {\n\
\x20 // student's system test\n\
\x20 EXPECT_TRUE(true);\n\
}\n",
);
// Step 3: Anvil upgrades. Simulate by tampering a managed file.
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# old version\n");
tamper(tmp.path(), "test/test_weather.cpp", "// old example\n");
// Student runs refresh
refresh(tmp.path(), true, None);
// Step 4: Student's tests survive
assert!(
contains(tmp.path(), "test/test_unit.cpp", "ReadsCorrectly"),
"Student's unit test must survive"
);
assert!(
contains(tmp.path(), "test/test_system.cpp", "RunsForAnHour"),
"Student's system test must survive"
);
// Managed files are restored
assert!(
!contains(tmp.path(), "build.sh", "# old version"),
"build.sh should be refreshed"
);
assert!(
contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"),
"test_weather.cpp should be refreshed"
);
// Step 5: Student wants to reset test_system.cpp to the template starter
refresh(tmp.path(), true, Some("test/test_system.cpp"));
assert!(
contains(tmp.path(), "test/test_system.cpp", "Your system tests go here"),
"File override should reset test_system.cpp"
);
// test_unit.cpp still has student's work
assert!(
contains(tmp.path(), "test/test_unit.cpp", "ReadsCorrectly"),
"test_unit.cpp should still have student's work"
);
// Step 6: Student adds protection for a custom file they created
fs::write(
tmp.path().join("test").join("test_helpers.cpp"),
"// shared helpers\n",
)
.unwrap();
add_ignore(tmp.path(), "test/test_helpers.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("test/test_helpers.cpp"));
}
// =========================================================================
// Refresh with basic template (no libraries, no template test)
// =========================================================================
#[test]
fn test_basic_project_refresh_works() {
let tmp = make_basic("bp");
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n");
refresh(tmp.path(), true, None);
assert!(
!contains(tmp.path(), "build.sh", "# tampered"),
"build.sh should be restored for basic project"
);
}
#[test]
fn test_basic_project_ignores_student_tests() {
let tmp = make_basic("bp");
tamper(tmp.path(), "test/test_unit.cpp", "// my custom\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// my custom\n",
"Basic project test_unit.cpp should be protected"
);
}
// =========================================================================
// .anvilignore itself is never refreshed or overwritten
// =========================================================================
#[test]
fn test_anvilignore_itself_not_refreshed() {
let tmp = make_weather("wx");
let _original = read(tmp.path(), ".anvilignore");
// Add a custom pattern
add_ignore(tmp.path(), "my_custom_pattern");
// Force refresh
refresh(tmp.path(), true, None);
// .anvilignore should still have the custom pattern
assert!(
contains(tmp.path(), ".anvilignore", "my_custom_pattern"),
".anvilignore should not be overwritten by refresh"
);
}
// =========================================================================
// Edge: delete a managed file, refresh recreates it
// =========================================================================
#[test]
fn test_refresh_recreates_deleted_managed_file() {
let tmp = make_weather("wx");
let build_sh = tmp.path().join("build.sh");
assert!(build_sh.exists());
fs::remove_file(&build_sh).unwrap();
assert!(!build_sh.exists());
// Even without --force, missing files should be created
refresh(tmp.path(), false, None);
assert!(build_sh.exists(), "Deleted managed file should be recreated");
}
#[test]
fn test_refresh_recreates_deleted_mock_header() {
let tmp = make_weather("wx");
let mock = tmp.path().join("test").join("mocks").join("mock_hal.h");
fs::remove_file(&mock).unwrap();
refresh(tmp.path(), false, None);
assert!(mock.exists(), "Deleted mock header should be recreated");
}
// =========================================================================
// ASCII check
// =========================================================================
#[test]
fn test_all_managed_files_ascii_after_refresh() {
let tmp = make_weather("wx");
// Tamper and force refresh to exercise the full write path
tamper(tmp.path(), "build.sh", "#!/bin/bash\n");
tamper(tmp.path(), "test/test_weather.cpp", "// x\n");
refresh(tmp.path(), true, None);
// Check every file under test/ and lib/ for non-ASCII
for entry in walkdir(tmp.path()) {
let content = fs::read(&entry).unwrap();
for (i, &byte) in content.iter().enumerate() {
assert!(
byte < 128,
"Non-ASCII byte {} at offset {} in {}",
byte,
i,
entry.display()
);
}
}
}
fn walkdir(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut files = vec![];
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(walkdir(&path));
} else {
files.push(path);
}
}
}
files
}
// =========================================================================
// Multi-file simultaneous modification
// =========================================================================
#[test]
fn test_simultaneous_modifications_across_categories() {
// Tamper with files in every category at once and verify each
// category behaves correctly after --force.
let tmp = make_weather("wx");
// Category 1: managed scripts (should be restored)
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# tampered\n");
tamper(tmp.path(), "upload.sh", "#!/bin/bash\n# tampered\n");
tamper(tmp.path(), "test.sh", "#!/bin/bash\n# tampered\n");
// Category 2: managed mocks (should be restored)
tamper(tmp.path(), "test/mocks/mock_hal.h", "// tampered mock_hal\n");
tamper(tmp.path(), "test/mocks/sim_hal.h", "// tampered sim_hal\n");
// Category 3: managed template test (should be restored)
tamper(tmp.path(), "test/test_weather.cpp", "// tampered weather\n");
// Category 4: managed library headers (should be restored)
tamper(
tmp.path(),
"lib/drivers/tmp36/tmp36.h",
"// tampered driver\n",
);
// Category 5: managed HAL (should be restored)
tamper(tmp.path(), "lib/hal/hal.h", "// tampered hal\n");
// Category 6: ignored student files (should survive)
tamper(tmp.path(), "test/test_unit.cpp", "// student unit work\n");
tamper(tmp.path(), "test/test_system.cpp", "// student system work\n");
// Category 7: ignored app code (should survive)
tamper(tmp.path(), "lib/app/wx_app.h", "// student app code\n");
// One big force refresh
refresh(tmp.path(), true, None);
// Verify category 1: restored
assert!(
!contains(tmp.path(), "build.sh", "# tampered"),
"build.sh should be restored"
);
assert!(
!contains(tmp.path(), "upload.sh", "# tampered"),
"upload.sh should be restored"
);
assert!(
!contains(tmp.path(), "test.sh", "# tampered"),
"test.sh should be restored"
);
// Verify category 2: restored
assert!(
contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"),
"mock_hal.h should be restored"
);
assert!(
contains(tmp.path(), "test/mocks/sim_hal.h", "SimHal"),
"sim_hal.h should be restored"
);
// Verify category 3: restored
assert!(
contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"),
"test_weather.cpp should be restored"
);
// Verify category 4: restored
assert!(
contains(tmp.path(), "lib/drivers/tmp36/tmp36.h", "TempSensor"),
"tmp36.h should be restored"
);
// Verify category 5: restored
assert!(
contains(tmp.path(), "lib/hal/hal.h", "class Hal"),
"hal.h should be restored"
);
// Verify category 6: survived
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// student unit work\n",
);
assert_eq!(
read(tmp.path(), "test/test_system.cpp"),
"// student system work\n",
);
// Verify category 7: survived
assert_eq!(
read(tmp.path(), "lib/app/wx_app.h"),
"// student app code\n",
);
}
// =========================================================================
// Glob lifecycle: add glob -> modify -> force -> protected ->
// remove glob -> force -> restored
// =========================================================================
#[test]
fn test_glob_lifecycle_protect_then_unprotect() {
let tmp = make_weather("wx");
// Save originals for comparison
let orig_weather = read(tmp.path(), "test/test_weather.cpp");
let orig_tmp36 = read(tmp.path(), "test/test_tmp36.cpp");
// Add glob that covers managed test files
add_ignore(tmp.path(), "test/test_*.cpp");
// Tamper managed test files (now protected by glob)
tamper(tmp.path(), "test/test_weather.cpp", "// student weather\n");
tamper(tmp.path(), "test/test_tmp36.cpp", "// student tmp36\n");
// Force refresh -- both protected by glob
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
"// student weather\n",
"Glob should protect test_weather.cpp"
);
assert_eq!(
read(tmp.path(), "test/test_tmp36.cpp"),
"// student tmp36\n",
"Glob should protect test_tmp36.cpp"
);
// Remove the glob pattern
remove_ignore(tmp.path(), "test/test_*.cpp");
// Note: test_unit.cpp and test_system.cpp have their own exact patterns
// so they remain protected even after glob removal.
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(
ignore.is_ignored("test/test_unit.cpp"),
"Exact pattern for test_unit should survive glob removal"
);
assert!(
!ignore.is_ignored("test/test_weather.cpp"),
"test_weather should no longer be protected"
);
// Force refresh -- glob removed, managed files restored
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
orig_weather,
"After glob removal, test_weather.cpp should be restored"
);
assert_eq!(
read(tmp.path(), "test/test_tmp36.cpp"),
orig_tmp36,
"After glob removal, test_tmp36.cpp should be restored"
);
}
// =========================================================================
// Pattern layering: exact + glob, remove exact -> still protected by glob
// =========================================================================
#[test]
fn test_pattern_layering_exact_plus_glob() {
let tmp = make_weather("wx");
// Default has exact "test/test_unit.cpp".
// Add a glob that also covers it.
add_ignore(tmp.path(), "test/test_*.cpp");
// Verify: matched by exact pattern first
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert_eq!(
ignore.matching_pattern("test/test_unit.cpp"),
Some("test/test_unit.cpp"),
"Exact pattern should match first"
);
// Remove the exact pattern
remove_ignore(tmp.path(), "test/test_unit.cpp");
// Still protected by the glob
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(
ignore.is_ignored("test/test_unit.cpp"),
"Glob should still protect test_unit.cpp after exact removal"
);
assert_eq!(
ignore.matching_pattern("test/test_unit.cpp"),
Some("test/test_*.cpp"),
"Now matched by glob pattern"
);
// Tamper and force -- still protected
tamper(tmp.path(), "test/test_unit.cpp", "// still protected\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// still protected\n",
);
// Remove the glob too -- now unprotected
remove_ignore(tmp.path(), "test/test_*.cpp");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(!ignore.is_ignored("test/test_unit.cpp"));
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
"With both patterns gone, file should be restored"
);
}
// =========================================================================
// Re-ignore after unignore: round-trip protection
// =========================================================================
#[test]
fn test_reignore_after_unignore() {
let tmp = make_weather("wx");
let original = read(tmp.path(), "test/test_unit.cpp");
// Unignore
remove_ignore(tmp.path(), "test/test_unit.cpp");
// Tamper and force -- gets restored since unprotected
tamper(tmp.path(), "test/test_unit.cpp", "// custom\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
original,
"Unprotected file should be restored"
);
// Re-add the ignore
add_ignore(tmp.path(), "test/test_unit.cpp");
// Now tamper and force -- protected again
tamper(tmp.path(), "test/test_unit.cpp", "// custom again\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// custom again\n",
"Re-ignored file should be protected"
);
}
// =========================================================================
// Multiple sequential force refreshes
// =========================================================================
#[test]
fn test_repeated_force_refreshes_idempotent() {
let tmp = make_weather("wx");
let orig_build = read(tmp.path(), "build.sh");
// Tamper, force, tamper, force, tamper, force
for i in 0..3 {
tamper(
tmp.path(),
"build.sh",
&format!("#!/bin/bash\n# round {}\n", i),
);
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "build.sh"),
orig_build,
"build.sh should be restored on round {}",
i
);
}
// Ignored file should survive all rounds
tamper(tmp.path(), "test/test_unit.cpp", "// stable\n");
for _ in 0..3 {
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_unit.cpp"),
"// stable\n",
"Ignored file should survive every round"
);
}
}
// =========================================================================
// --file with glob protection: only the named file is overridden
// =========================================================================
#[test]
fn test_file_override_with_glob_surgical_precision() {
let tmp = make_weather("wx");
// Glob-protect all mock headers
add_ignore(tmp.path(), "test/mocks/*.h");
// Tamper three mock headers
tamper(tmp.path(), "test/mocks/mock_hal.h", "// custom mock_hal\n");
tamper(tmp.path(), "test/mocks/sim_hal.h", "// custom sim_hal\n");
tamper(tmp.path(), "test/mocks/mock_arduino.h", "// custom arduino\n");
// Force with --file for just mock_hal.h
refresh(tmp.path(), true, Some("test/mocks/mock_hal.h"));
// mock_hal.h restored, others still custom
assert!(
contains(tmp.path(), "test/mocks/mock_hal.h", "MockHal"),
"Named file should be restored"
);
assert_eq!(
read(tmp.path(), "test/mocks/sim_hal.h"),
"// custom sim_hal\n",
"Non-named glob-protected file should survive"
);
assert_eq!(
read(tmp.path(), "test/mocks/mock_arduino.h"),
"// custom arduino\n",
"Non-named glob-protected file should survive"
);
}
// =========================================================================
// Whole-directory glob protection
// =========================================================================
#[test]
fn test_directory_glob_protects_all_children() {
let tmp = make_weather("wx");
// Protect the entire drivers tree
add_ignore(tmp.path(), "lib/drivers/*");
// Tamper all driver headers
tamper(tmp.path(), "lib/drivers/tmp36/tmp36.h", "// custom\n");
tamper(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h", "// custom\n");
tamper(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h", "// custom\n");
// Force -- all protected
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "lib/drivers/tmp36/tmp36.h"),
"// custom\n",
);
assert_eq!(
read(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h"),
"// custom\n",
);
assert_eq!(
read(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h"),
"// custom\n",
);
// --file override for just one
refresh(tmp.path(), true, Some("lib/drivers/tmp36/tmp36.h"));
assert!(
contains(tmp.path(), "lib/drivers/tmp36/tmp36.h", "TempSensor"),
"Named file should be restored through directory glob"
);
assert_eq!(
read(tmp.path(), "lib/drivers/tmp36/tmp36_mock.h"),
"// custom\n",
"Other driver files still protected"
);
}
// =========================================================================
// Sketch protection via default .anvilignore
// =========================================================================
#[test]
fn test_sketch_protected_by_default() {
let tmp = make_weather("wx");
// The default .anvilignore has a */*.ino pattern
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(
ignore.is_ignored("wx/wx.ino"),
"Sketch .ino should be protected by default"
);
// Tamper sketch and force -- should survive
tamper(tmp.path(), "wx/wx.ino", "// student sketch\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "wx/wx.ino"),
"// student sketch\n",
"Sketch should survive force refresh"
);
}
// =========================================================================
// .anvilignore itself is in NEVER_REFRESH, cannot be overwritten
// =========================================================================
#[test]
fn test_anvilignore_survives_even_if_not_in_patterns() {
let tmp = make_weather("wx");
// Write a completely custom .anvilignore
fs::write(
tmp.path().join(".anvilignore"),
"# my very custom ignore\nonly_this_one.txt\n",
)
.unwrap();
// Force refresh
refresh(tmp.path(), true, None);
// .anvilignore should still have our custom content
let content = read(tmp.path(), ".anvilignore");
assert!(
content.contains("# my very custom ignore"),
".anvilignore must never be overwritten by refresh"
);
assert!(
content.contains("only_this_one.txt"),
"Custom patterns must survive"
);
}
#[test]
fn test_anvilignore_not_overwritten_even_with_file_flag() {
// Even --file .anvilignore should not work because NEVER_REFRESH
let tmp = make_weather("wx");
fs::write(
tmp.path().join(".anvilignore"),
"# custom\n",
)
.unwrap();
refresh(tmp.path(), true, Some(".anvilignore"));
let content = read(tmp.path(), ".anvilignore");
assert_eq!(
content, "# custom\n",
".anvilignore must survive even --file override"
);
}
// =========================================================================
// Delete multiple managed files, recreated without --force
// =========================================================================
#[test]
fn test_multiple_deleted_files_recreated_without_force() {
let tmp = make_weather("wx");
// Delete several managed files
fs::remove_file(tmp.path().join("build.sh")).unwrap();
fs::remove_file(tmp.path().join("upload.sh")).unwrap();
fs::remove_file(tmp.path().join("test").join("run_tests.sh")).unwrap();
fs::remove_file(
tmp.path().join("test").join("mocks").join("mock_hal.h"),
)
.unwrap();
// Refresh without --force should recreate missing files
refresh(tmp.path(), false, None);
assert!(
tmp.path().join("build.sh").exists(),
"build.sh should be recreated"
);
assert!(
tmp.path().join("upload.sh").exists(),
"upload.sh should be recreated"
);
assert!(
tmp.path().join("test").join("run_tests.sh").exists(),
"run_tests.sh should be recreated"
);
assert!(
tmp.path()
.join("test")
.join("mocks")
.join("mock_hal.h")
.exists(),
"mock_hal.h should be recreated"
);
}
// =========================================================================
// Delete an ignored file -- refresh does NOT recreate it (still ignored)
// =========================================================================
#[test]
fn test_deleted_ignored_file_not_recreated() {
let tmp = make_weather("wx");
// test_unit.cpp is ignored
let test_unit = tmp.path().join("test").join("test_unit.cpp");
assert!(test_unit.exists());
fs::remove_file(&test_unit).unwrap();
assert!(!test_unit.exists());
// Force refresh -- file is ignored so should NOT be recreated
refresh(tmp.path(), true, None);
assert!(
!test_unit.exists(),
"Deleted ignored file should NOT be recreated by refresh"
);
}
#[test]
fn test_deleted_ignored_file_recreated_with_file_override() {
let tmp = make_weather("wx");
let test_unit = tmp.path().join("test").join("test_unit.cpp");
fs::remove_file(&test_unit).unwrap();
// --file override should recreate even though ignored
refresh(tmp.path(), true, Some("test/test_unit.cpp"));
assert!(
test_unit.exists(),
"--file override should recreate deleted ignored file"
);
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
);
}
// =========================================================================
// Without --force, tampered files are left alone (only missing created)
// =========================================================================
#[test]
fn test_no_force_tampered_file_left_alone_missing_created() {
let tmp = make_weather("wx");
// Tamper one file and delete another
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# old\n");
fs::remove_file(tmp.path().join("upload.sh")).unwrap();
refresh(tmp.path(), false, None);
// Tampered file untouched (no --force)
assert!(
contains(tmp.path(), "build.sh", "# old"),
"Without --force, tampered file should remain"
);
// Missing file recreated
assert!(
tmp.path().join("upload.sh").exists(),
"Missing file should be recreated even without --force"
);
}
// =========================================================================
// Complex lifecycle: student works through the entire semester
// =========================================================================
#[test]
fn test_full_semester_lifecycle() {
// A realistic multi-step scenario simulating a student over time:
//
// Week 1: Create project, start writing tests
// Week 3: Anvil v2 released, student refreshes
// Week 5: Student protects a custom helper file
// Week 7: Student accidentally deletes a script, refreshes to recover
// Week 9: Student decides to reset their system test to starter
// Week 11: Student protects all their custom test files with a glob
// Week 13: Course ends, student unprotects everything for grading
// == Week 1: Create and start writing ==
let tmp = make_weather("station");
tamper(
tmp.path(),
"test/test_unit.cpp",
"#include <gtest/gtest.h>\n\
#include \"tmp36_mock.h\"\n\
#include \"station_app.h\"\n\
TEST(StationUnit, ReadsTemp) {\n\
\x20 Tmp36Mock sensor;\n\
\x20 sensor.setTemperature(22.5f);\n\
\x20 EXPECT_NEAR(sensor.readCelsius(), 22.5f, 0.1f);\n\
}\n",
);
tamper(
tmp.path(),
"test/test_system.cpp",
"#include <gtest/gtest.h>\n\
TEST(StationSys, RunsOneMinute) {\n\
\x20 // TODO: implement\n\
\x20 EXPECT_TRUE(true);\n\
}\n",
);
// == Week 3: Anvil v2 -- scripts change, student refreshes ==
tamper(tmp.path(), "build.sh", "#!/bin/bash\n# v1 build\n");
tamper(tmp.path(), "test/test_weather.cpp", "// v1 examples\n");
refresh(tmp.path(), true, None);
// Student's tests survived
assert!(
contains(tmp.path(), "test/test_unit.cpp", "StationUnit"),
"Week 3: student unit tests must survive"
);
assert!(
contains(tmp.path(), "test/test_system.cpp", "StationSys"),
"Week 3: student system tests must survive"
);
// Managed files updated
assert!(
!contains(tmp.path(), "build.sh", "# v1 build"),
"Week 3: build.sh should be refreshed"
);
assert!(
contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"),
"Week 3: test_weather.cpp should be refreshed"
);
// == Week 5: Student creates a helper and protects it ==
fs::create_dir_all(tmp.path().join("test").join("helpers")).unwrap();
fs::write(
tmp.path().join("test").join("helpers").join("test_utils.h"),
"// shared test utilities\n",
)
.unwrap();
add_ignore(tmp.path(), "test/helpers/*");
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("test/helpers/test_utils.h"));
// == Week 7: Student accidentally deletes build.sh, recovers ==
fs::remove_file(tmp.path().join("build.sh")).unwrap();
refresh(tmp.path(), false, None);
assert!(
tmp.path().join("build.sh").exists(),
"Week 7: deleted script should be recreated"
);
// == Week 9: Student wants to restart system tests from template ==
assert!(
contains(tmp.path(), "test/test_system.cpp", "StationSys"),
"Before reset, student's work is present"
);
refresh(tmp.path(), true, Some("test/test_system.cpp"));
assert!(
contains(
tmp.path(),
"test/test_system.cpp",
"Your system tests go here"
),
"Week 9: system test should be reset to starter"
);
// Unit test still has student's work
assert!(
contains(tmp.path(), "test/test_unit.cpp", "StationUnit"),
"Week 9: unit test should still have student's work"
);
// == Week 11: Student adds glob for all their custom test files ==
fs::write(
tmp.path().join("test").join("test_integration.cpp"),
"// student integration tests\n",
)
.unwrap();
add_ignore(tmp.path(), "test/test_integration.cpp");
// Verify protection stacking
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored("test/test_unit.cpp"));
assert!(ignore.is_ignored("test/test_system.cpp"));
assert!(ignore.is_ignored("test/test_integration.cpp"));
assert!(ignore.is_ignored("test/helpers/test_utils.h"));
assert!(!ignore.is_ignored("test/test_weather.cpp"));
// == Week 13: For grading, remove all custom protection ==
remove_ignore(tmp.path(), "test/test_unit.cpp");
remove_ignore(tmp.path(), "test/test_system.cpp");
remove_ignore(tmp.path(), "test/test_integration.cpp");
remove_ignore(tmp.path(), "test/helpers/*");
// Config and app code still protected by remaining patterns
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.is_ignored(".anvil.toml"));
assert!(ignore.is_ignored("lib/app/station_app.h"));
assert!(!ignore.is_ignored("test/test_unit.cpp"));
}
// =========================================================================
// Refresh correctly uses the config template field
// =========================================================================
#[test]
fn test_refresh_basic_does_not_have_template_test() {
let tmp = make_basic("bp");
tamper(tmp.path(), "build.sh", "# tampered\n");
refresh(tmp.path(), true, None);
// build.sh refreshed
assert!(
!contains(tmp.path(), "build.sh", "# tampered"),
);
// No test_basic.cpp should exist (basic template has no example test)
assert!(
!tmp.path().join("test").join("test_basic.cpp").exists(),
"Basic template should NOT produce a test_basic.cpp"
);
}
// =========================================================================
// Refresh after config template change
// =========================================================================
#[test]
fn test_refresh_after_manual_template_change() {
// If someone changes config.project.template, refresh adapts.
// This is an edge case -- switching templates mid-project isn't
// officially supported, but refresh shouldn't crash.
let tmp = make_weather("wx");
// Change template to "basic" in config
let mut config = ProjectConfig::load(tmp.path()).unwrap();
config.project.template = "basic".to_string();
config.save(tmp.path()).unwrap();
// Refresh should still work (extracts basic template, not weather)
refresh(tmp.path(), true, None);
// Should not crash -- that's the main assertion
assert!(tmp.path().join("build.sh").exists());
}
// =========================================================================
// Edge: --force with no changes should be a no-op
// =========================================================================
#[test]
fn test_force_on_clean_project_is_noop() {
let tmp = make_weather("wx");
// Save content of a managed file
let before = read(tmp.path(), "build.sh");
// Force refresh on a clean project
refresh(tmp.path(), true, None);
// Content should be byte-identical
let after = read(tmp.path(), "build.sh");
assert_eq!(before, after, "Force on clean project should be a no-op");
}
// =========================================================================
// Glob with nested directories
// =========================================================================
#[test]
fn test_glob_star_matches_nested_paths() {
let tmp = make_weather("wx");
// lib/drivers/tmp36/* should match files inside
add_ignore(tmp.path(), "lib/drivers/tmp36/*");
tamper(tmp.path(), "lib/drivers/tmp36/tmp36.h", "// protected\n");
tamper(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h", "// protected\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "lib/drivers/tmp36/tmp36.h"),
"// protected\n",
);
assert_eq!(
read(tmp.path(), "lib/drivers/tmp36/tmp36_sim.h"),
"// protected\n",
);
}
// =========================================================================
// Edge: adding multiple glob patterns that overlap
// =========================================================================
#[test]
fn test_multiple_overlapping_globs() {
let tmp = make_weather("wx");
add_ignore(tmp.path(), "test/*.cpp");
add_ignore(tmp.path(), "test/test_*.cpp");
tamper(tmp.path(), "test/test_weather.cpp", "// protected\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
"// protected\n",
"File matched by multiple globs should be protected"
);
// Remove one glob -- still protected by the other
remove_ignore(tmp.path(), "test/test_*.cpp");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
"// protected\n",
"Still protected by remaining glob"
);
// Remove the other glob -- now unprotected
remove_ignore(tmp.path(), "test/*.cpp");
// Note: exact pattern for test_unit and test_system still exists
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(
!ignore.is_ignored("test/test_weather.cpp"),
"test_weather.cpp should now be unprotected"
);
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_weather.cpp", "WeatherUnitTest"),
"Unprotected file should be restored"
);
}
// =========================================================================
// Edge: empty .anvilignore vs missing .anvilignore
// =========================================================================
#[test]
fn test_empty_vs_missing_anvilignore_equivalent() {
// Both should result in nothing being protected.
let tmp1 = make_weather("wx1");
let tmp2 = make_weather("wx2");
// tmp1: empty .anvilignore
fs::write(tmp1.path().join(".anvilignore"), "").unwrap();
// tmp2: delete .anvilignore
fs::remove_file(tmp2.path().join(".anvilignore")).unwrap();
// Tamper same file in both
tamper(tmp1.path(), "test/test_unit.cpp", "// custom\n");
tamper(tmp2.path(), "test/test_unit.cpp", "// custom\n");
// Force refresh both
refresh(tmp1.path(), true, None);
refresh(tmp2.path(), true, None);
// Both should have file restored (nothing protected)
assert!(
contains(tmp1.path(), "test/test_unit.cpp", "Your unit tests go here"),
"Empty .anvilignore: file should be restored"
);
assert!(
contains(tmp2.path(), "test/test_unit.cpp", "Your unit tests go here"),
"Missing .anvilignore: file should be restored"
);
}
// =========================================================================
// Edge: only comments in .anvilignore
// =========================================================================
#[test]
fn test_comments_only_anvilignore_protects_nothing() {
let tmp = make_weather("wx");
fs::write(
tmp.path().join(".anvilignore"),
"# just comments\n# nothing real\n# more comments\n",
)
.unwrap();
tamper(tmp.path(), "test/test_unit.cpp", "// custom\n");
refresh(tmp.path(), true, None);
assert!(
contains(tmp.path(), "test/test_unit.cpp", "Your unit tests go here"),
"Comments-only .anvilignore should protect nothing"
);
}
// =========================================================================
// Verify ignore patterns persist across multiple refreshes
// =========================================================================
#[test]
fn test_added_patterns_persist_across_refreshes() {
let tmp = make_weather("wx");
add_ignore(tmp.path(), "test/test_weather.cpp");
tamper(tmp.path(), "test/test_weather.cpp", "// custom\n");
// Refresh 1
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
"// custom\n",
);
// Refresh 2 -- pattern should still be in .anvilignore
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "test/test_weather.cpp"),
"// custom\n",
"Added patterns must persist across refreshes"
);
// Verify the pattern is still in the file
let ignore = AnvilIgnore::load(tmp.path()).unwrap();
assert!(ignore.has_pattern("test/test_weather.cpp"));
}
// =========================================================================
// Edge: refresh with all scripts deleted AND modified files
// =========================================================================
#[test]
fn test_refresh_with_mixed_deleted_and_modified() {
let tmp = make_weather("wx");
// Delete some managed files
fs::remove_file(tmp.path().join("build.sh")).unwrap();
fs::remove_file(tmp.path().join("upload.sh")).unwrap();
// Tamper others
tamper(tmp.path(), "monitor.sh", "#!/bin/bash\n# old monitor\n");
tamper(tmp.path(), "test.sh", "#!/bin/bash\n# old test\n");
// Refresh without --force: missing files created, tampered left alone
refresh(tmp.path(), false, None);
assert!(tmp.path().join("build.sh").exists(), "Deleted: recreated");
assert!(tmp.path().join("upload.sh").exists(), "Deleted: recreated");
assert!(
contains(tmp.path(), "monitor.sh", "# old monitor"),
"Tampered without --force: left alone"
);
// Now force
refresh(tmp.path(), true, None);
assert!(
!contains(tmp.path(), "monitor.sh", "# old monitor"),
"Tampered with --force: restored"
);
assert!(
!contains(tmp.path(), "test.sh", "# old test"),
"Tampered with --force: restored"
);
}
// =========================================================================
// Edge: tamper a .bat file (Windows script), verify restoration
// =========================================================================
#[test]
fn test_bat_scripts_managed_and_refreshed() {
let tmp = make_weather("wx");
let orig = read(tmp.path(), "build.bat");
tamper(tmp.path(), "build.bat", "@echo off\nREM tampered\n");
tamper(tmp.path(), "upload.bat", "@echo off\nREM tampered\n");
refresh(tmp.path(), true, None);
assert_eq!(
read(tmp.path(), "build.bat"),
orig,
"build.bat should be restored"
);
assert!(
!contains(tmp.path(), "upload.bat", "REM tampered"),
"upload.bat should be restored"
);
}
// =========================================================================
// Edge: add a pattern, verify it's there, add it again, still only once
// =========================================================================
#[test]
fn test_add_ignore_truly_idempotent_with_refresh() {
let tmp = make_weather("wx");
// The default already has test/test_unit.cpp
add_ignore(tmp.path(), "test/test_unit.cpp");
add_ignore(tmp.path(), "test/test_unit.cpp");
add_ignore(tmp.path(), "test/test_unit.cpp");
let content = read(tmp.path(), ".anvilignore");
let count = content
.lines()
.filter(|l| l.trim() == "test/test_unit.cpp")
.count();
assert_eq!(count, 1, "Pattern must appear exactly once");
// Refresh still works fine
tamper(tmp.path(), "test/test_unit.cpp", "// custom\n");
refresh(tmp.path(), true, None);
assert_eq!(read(tmp.path(), "test/test_unit.cpp"), "// custom\n");
}
// =========================================================================
// Verify all scripts (.sh and .bat) are in the managed set
// =========================================================================
#[test]
fn test_all_scripts_are_managed() {
let tmp = make_weather("wx");
let scripts = [
"build.sh",
"build.bat",
"upload.sh",
"upload.bat",
"monitor.sh",
"monitor.bat",
"test.sh",
"test.bat",
"test/run_tests.sh",
"test/run_tests.bat",
];
for script in &scripts {
let orig = read(tmp.path(), script);
tamper(tmp.path(), script, "TAMPERED");
refresh(tmp.path(), true, None);
let after = read(tmp.path(), script);
assert_eq!(
after, orig,
"{} should be managed and restored by refresh",
script
);
}
}