UI-15: project_lifecycle (create/open/edit/save/restart/switch/undo with A/B isolation and fail-closed corrupt/future/legacy cases), script_limits (adversarial corpus), bvh_differential (randomized brute-force comparison with tie discipline), export_interop (queue discipline, STL/DXF/GLB semantics, grid termination), runtime_ui (full lifecycle plus desktop/mobile budgets and soak). cad.yml: CORE canonical-module gates, UI session-module gates (switch/rebuild/journal/sandbox/ai/cache/BVH/coords/capture/exports), integration-lane ownership notes; lanes otherwise unchanged.
191 lines
7.1 KiB
Rust
191 lines
7.1 KiB
Rust
//! 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 `<root>/cad/<slug>.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);
|
|
}
|