//! UI-15 `project_lifecycle` — real create/open/edit/save/restart/ //! switch/undo/export/cancel coverage at the session level. //! //! Asserts actions and durable state, not widget visibility. Uses //! temporary roots only (production-root access is structurally //! impossible: every repo function takes an injected root). use cad_ui::journal::{CommandFamily, CommandJournal}; use cad_ui::project_repo::{ create_project, import_legacy, open_project, save_source, OpenOutcome, ProjectManifest, RepoRoot, MANIFEST_FILE, }; use cad_ui::scene_holder::CadDocument; use cad_ui::session_controller::{ CadSessionController, DocumentId, ProjectId, Revision, SessionId, }; use cad_ui::session_switch::{switch_project, CandidateSession, SwitchOutcome, SwitchableState}; use std::sync::atomic::{AtomicU32, Ordering}; fn temp_root(tag: &str) -> RepoRoot { static COUNTER: AtomicU32 = AtomicU32::new(0); let dir = std::env::temp_dir().join(format!( "nigig_cad_lifecycle_{tag}_{}_{}", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed) )); let _ = std::fs::remove_dir_all(&dir); RepoRoot::new(dir) } fn cleanup(root: &RepoRoot) { let _ = std::fs::remove_dir_all(root.path()); } #[test] fn create_open_edit_save_restart_round_trip() { let root = temp_root("restart"); let manifest = create_project(&root, "proj_restart", "Restart", 1).expect("create"); // Open: ready with empty source. match open_project(&root, "proj_restart") { OpenOutcome::Ready { .. } => {} other => panic!("expected Ready, got {other:?}"), } let source_v1 = "render(cube(1))"; let manifest = save_source(&root, &manifest, source_v1, 1).expect("save v1"); assert_eq!(manifest.revision, 1); // Simulate restart: fresh handles, same root. match open_project(&root, "proj_restart") { OpenOutcome::Ready { source, manifest, .. } => { assert_eq!(source, source_v1); assert_eq!(manifest.revision, 1); } other => panic!("expected Ready after restart, got {other:?}"), } cleanup(&root); } #[test] fn ab_switching_has_zero_state_leakage() { let root = temp_root("ab"); let ma = create_project(&root, "proj_a", "A", 1).unwrap(); let mb = create_project(&root, "proj_b", "B", 2).unwrap(); let _ma = save_source(&root, &ma, "source-A", 1).unwrap(); let _mb = save_source(&root, &mb, "source-B", 1).unwrap(); // Session on A with dirty state. let store = CadDocument::shared(); let controller_a = CadSessionController::open( SessionId::new(1), ProjectId::new(1), DocumentId::new(1), store, &cad_ui::scene_holder::PartIdAllocator::new(1), ) .unwrap(); let mut state = SwitchableState::dirty("A"); let mut active = ( ProjectId::new(1), DocumentId::new(1), controller_a.revision(), ); // Switch to B (candidate validated off-screen first). let candidate = CandidateSession { session: SessionId::new(1), project: ProjectId::new(2), document: DocumentId::new(2), revision: Revision(0), }; assert_eq!( switch_project(&mut state, &mut active, candidate, || { match open_project(&root, "proj_b") { OpenOutcome::Ready { .. } => Ok(()), other => Err(format!("B not ready: {other:?}")), } }), SwitchOutcome::Switched ); assert!(state.is_reset(), "no A state may cross into B"); // B's durable source is intact and is B's, not A's. match open_project(&root, "proj_b") { OpenOutcome::Ready { source, .. } => assert_eq!(source, "source-B"), other => panic!("B corrupted: {other:?}"), } // A is untouched. match open_project(&root, "proj_a") { OpenOutcome::Ready { source, .. } => assert_eq!(source, "source-A"), other => panic!("A corrupted: {other:?}"), } cleanup(&root); } #[test] fn undo_round_trip_and_save_after_edit() { let root = temp_root("undo"); let manifest = create_project(&root, "proj_undo", "U", 1).unwrap(); let mut journal = CommandJournal::new(Revision(0)); journal .commit( CommandFamily::ScriptReplace, Revision(0), vec!["v0".into()], vec!["v1".into()], ) .unwrap(); let restored = journal.undo().unwrap(); assert_eq!(restored, vec!["v0".to_string()]); // Save the undone state: reopen reproduces it. let manifest = save_source(&root, &manifest, &restored[0], 1).unwrap(); assert_eq!(manifest.revision, 1); match open_project(&root, "proj_undo") { OpenOutcome::Ready { source, .. } => assert_eq!(source, "v0"), other => panic!("{other:?}"), } cleanup(&root); } #[test] fn corrupt_and_future_projects_fail_closed() { let root = temp_root("failclosed"); create_project(&root, "proj_c", "C", 1).unwrap(); // Corrupt the manifest. let dir = root.path().join("projects").join("proj_c"); std::fs::write(dir.join(MANIFEST_FILE), "{bad json").unwrap(); match open_project(&root, "proj_c") { OpenOutcome::Corrupt { .. } | OpenOutcome::IoError { .. } => {} other => panic!("corrupt must fail closed, got {other:?}"), } // Future schema quarantines. create_project(&root, "proj_f", "F", 1).unwrap(); let dir2 = root.path().join("projects").join("proj_f"); let mut manifest: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir2.join(MANIFEST_FILE)).unwrap()).unwrap(); manifest["schema_version"] = serde_json::json!(u32::MAX); std::fs::write( dir2.join(MANIFEST_FILE), serde_json::to_vec(&manifest).unwrap(), ) .unwrap(); match open_project(&root, "proj_f") { OpenOutcome::UnsupportedFuture { .. } => {} other => panic!("future must quarantine, got {other:?}"), } cleanup(&root); } #[test] fn legacy_import_preserves_bytes_and_needs_migration() { let root = temp_root("legacy"); // Legacy layout is `/cad/.cad` (see open_legacy). let legacy_dir = root.path().join("cad"); std::fs::create_dir_all(&legacy_dir).unwrap(); std::fs::write(legacy_dir.join("proj_old.cad"), "render(cube(1))").unwrap(); match open_project(&root, "proj_old") { OpenOutcome::NeedsMigration { source, .. } => assert_eq!(source, "render(cube(1))"), other => panic!("expected NeedsMigration, got {other:?}"), } // Import stages a copy; the legacy file is byte-identical. let before = std::fs::read(legacy_dir.join("proj_old.cad")).unwrap(); let manifest: ProjectManifest = import_legacy(&root, "proj_old", "Old", 9).expect("import"); let after = std::fs::read(legacy_dir.join("proj_old.cad")).unwrap(); assert_eq!(before, after); assert_eq!(manifest.slug, "proj_old"); // The staged project now opens Ready. match open_project(&root, "proj_old") { OpenOutcome::Ready { source, .. } => assert_eq!(source, "render(cube(1))"), other => panic!("staged project must open Ready, got {other:?}"), } cleanup(&root); }