/// 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 \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 \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 { 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 \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 \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 ); } }