nigig-org/crates/apps/cad/cad-ui/tests/project_lifecycle.rs
andodeki 38feca50a1
Some checks failed
cad / cad-truth-gates (push) Has been cancelled
cad / cad-core-checks (push) Has been cancelled
cad / cad-consumers (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
ci(cad): UI-15 runtime matrix plus CORE/UI truth gates
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.
2026-09-26 05:04:20 +03:00

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);
}