Integrate release recovery, semantic review, coverage and Windows packaging
This commit is contained in:
@@ -0,0 +1,479 @@
|
||||
//! Home navigation and asynchronous private checkpoints share the normal guards.
|
||||
use super::*;
|
||||
use crate::{home, recovery};
|
||||
|
||||
impl App {
|
||||
pub(super) fn remember_local(&mut self) {
|
||||
if let Some(path) = self.path.clone() {
|
||||
let name = self.project().name.clone();
|
||||
if let Err(error) = self.home.remember(&path, &name) {
|
||||
self.home.notice = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn home_event(&mut self, event: home::Event) -> Task<Message> {
|
||||
match event {
|
||||
home::Event::New => self.handle(Message::Request(Pending::New)),
|
||||
home::Event::Open => self.handle(Message::Request(Pending::Open)),
|
||||
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
|
||||
home::Event::Guide => self.update(Message::Page(Page::Guide)),
|
||||
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
|
||||
home::Event::OpenRecent(path) => {
|
||||
self.recent_target = Some(path);
|
||||
self.handle(Message::Request(Pending::Recent))
|
||||
}
|
||||
home::Event::Recover(id) => self.handle(Message::Request(Pending::Recover(id))),
|
||||
home::Event::Forget(path) => {
|
||||
if let Err(error) = self.home.forget(&path) {
|
||||
self.home.notice = Some(error);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
home::Event::Refresh => {
|
||||
self.home.refresh();
|
||||
self.recovery.refresh();
|
||||
self.recovery.entries.retain(|entry| {
|
||||
entry.id != self.canvas_document && Some(entry.id) != self.recovery.restored
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn checkpoint_tick(&mut self) -> Task<Message> {
|
||||
if self.recovery.writing || self.busy {
|
||||
return Task::none();
|
||||
}
|
||||
if !self.dirty() {
|
||||
self.retire_checkpoint();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(directory) = self.recovery.directory.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
let id = self.canvas_document;
|
||||
let snapshot = match recovery::Snapshot::capture(
|
||||
id,
|
||||
&self.document,
|
||||
&self.source_workspace,
|
||||
self.draft.as_ref(),
|
||||
&self.behavior,
|
||||
self.path.clone(),
|
||||
self.selected,
|
||||
)
|
||||
.and_then(|snapshot| snapshot.with_workflow(self.workflow.as_ref()))
|
||||
{
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(error) => {
|
||||
self.recovery.warning = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.recovery.writing = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || recovery::write(&directory, &snapshot))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
},
|
||||
move |result| Message::CheckpointWritten(id, result),
|
||||
)
|
||||
}
|
||||
pub(super) fn checkpoint_written(&mut self, id: Id, result: Result<(), String>) {
|
||||
self.recovery.writing = false;
|
||||
match result {
|
||||
Ok(()) if id == self.canvas_document && self.dirty() => {
|
||||
self.recovery.active = Some(id);
|
||||
}
|
||||
Ok(()) => {
|
||||
if let Some(directory) = &self.recovery.directory
|
||||
&& let Err(error) = recovery::remove(directory, id)
|
||||
{
|
||||
self.recovery.warning = Some(error);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.recovery.warning = Some(format!(
|
||||
"Recovery checkpoint failed: {error}. Save your work explicitly."
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn retire_checkpoint(&mut self) {
|
||||
let Some(directory) = &self.recovery.directory else {
|
||||
return;
|
||||
};
|
||||
for id in [self.recovery.active.take(), self.recovery.restored.take()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if let Err(error) = recovery::remove(directory, id) {
|
||||
self.recovery.warning = Some(error);
|
||||
} else {
|
||||
self.recovery.entries.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn load_checkpoint(&mut self, id: Id) -> Task<Message> {
|
||||
let Some(directory) = self.recovery.directory.clone() else {
|
||||
return Task::none();
|
||||
};
|
||||
self.busy = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || recovery::read(&directory, id).map(Arc::new))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
},
|
||||
Message::CheckpointLoaded,
|
||||
)
|
||||
}
|
||||
pub(super) fn checkpoint_loaded(
|
||||
&mut self,
|
||||
result: Result<Arc<recovery::Snapshot>, String>,
|
||||
) -> Task<Message> {
|
||||
self.busy = false;
|
||||
let snapshot = match result {
|
||||
Ok(s) => s,
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let id = snapshot.id;
|
||||
let restored = match (*snapshot).clone().restore() {
|
||||
Ok(r) => r,
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
// Fully prepare recovery before touching the currently accepted document.
|
||||
self.reset(restored.document.project().clone(), None);
|
||||
self.hosted.leave_document();
|
||||
self.document = restored.document;
|
||||
self.selected = if self.project().layer_of(restored.selected).is_some()
|
||||
|| restored.selected == self.project().id
|
||||
{
|
||||
restored.selected
|
||||
} else {
|
||||
self.project().id
|
||||
};
|
||||
// Installing the raw workspace must follow all accepted-source synchronization.
|
||||
self.source_workspace = restored.workspace;
|
||||
self.show_source_file(false);
|
||||
self.refresh_draft();
|
||||
if let Some(draft) = restored.property {
|
||||
self.selected = draft.original;
|
||||
self.exact_strings = draft.exact_strings;
|
||||
self.draft = Some(draft);
|
||||
}
|
||||
self.behavior = restored.behavior;
|
||||
self.workflow = restored.workflow;
|
||||
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
|
||||
if let Some(layer) = self.project().layer_of(focus) {
|
||||
self.layer = layer;
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
|
||||
self.active_diagram = self.diagram_for(layer, focus);
|
||||
}
|
||||
self.recovery.restored = Some(id);
|
||||
self.recovery.entries.retain(|entry| entry.id != id);
|
||||
self.page = if self.behavior.has_pending() {
|
||||
Page::Behavior
|
||||
} else if self.source_dirty() {
|
||||
Page::Text
|
||||
} else {
|
||||
Page::Diagram
|
||||
};
|
||||
self.status="Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into();
|
||||
self.request_lint()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_resume_returns_to_each_actual_workspace_without_model_edits() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
for page in [
|
||||
Page::Diagram,
|
||||
Page::Catalog,
|
||||
Page::Behavior,
|
||||
Page::Data,
|
||||
Page::Traces,
|
||||
Page::Text,
|
||||
Page::Validation,
|
||||
Page::Guide,
|
||||
] {
|
||||
let _ = app.update(Message::Page(page));
|
||||
assert_eq!(app.page, page);
|
||||
let selected = app.selected;
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
assert_eq!(app.last_workspace_page, page);
|
||||
// Repeated Home events must not replace the remembered workspace.
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, page);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_resume_keeps_state_view_while_explicit_architecture_remains_reachable() {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
|
||||
project = prepare_command(&project, &plan.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, plan.diagram);
|
||||
assert_eq!(app.behavior.active_owner, Some(machine));
|
||||
let _ = app.update(Message::Page(Page::Diagram));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.active_diagram.is_none());
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_resume_retains_invalid_source_and_property_drafts() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let raw = format!("{}\nunfinished draft", app.source_workspace.text());
|
||||
app.source_workspace
|
||||
.edit(raw.clone(), app.source_editor.cursor());
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Text);
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Data));
|
||||
let _ = app.update(Message::Field("/id".into(), "-".into()));
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
assert!(app.draft_dirty());
|
||||
let _ = app.update(Message::Page(Page::Home));
|
||||
let _ = app.update(Message::Home(home::Event::Resume));
|
||||
assert_eq!(app.page, Page::Data);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
fn retained_snapshot(app: &App) -> recovery::Snapshot {
|
||||
recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
app.path.clone(),
|
||||
app.selected,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_an_older_snapshot_keeps_recovery_for_newer_unsaved_work() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().join("recovery"));
|
||||
let id = app.project().id;
|
||||
app.execute(Command::Rename(id, "Saved milestone".into()));
|
||||
let path = directory.path().join(&app.document.bundle().entry);
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
app.execute(Command::Rename(id, "Later unsaved engineering".into()));
|
||||
let snapshot = retained_snapshot(&app);
|
||||
let recovery_directory = app.recovery.directory.clone().unwrap();
|
||||
recovery::write(&recovery_directory, &snapshot).unwrap();
|
||||
app.recovery.active = Some(app.canvas_document);
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.project().name, "Later unsaved engineering");
|
||||
assert_eq!(app.recovery.active, Some(app.canvas_document));
|
||||
let restored = recovery::read(&recovery_directory, app.canvas_document)
|
||||
.unwrap()
|
||||
.restore()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restored.document.project().name,
|
||||
"Later unsaved engineering"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkpoint_completion_releases_the_writer_while_a_context_menu_is_open() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().into());
|
||||
let id = app.project().id;
|
||||
app.execute(Command::Rename(id, "Unsaved engineering".into()));
|
||||
let snapshot = retained_snapshot(&app);
|
||||
recovery::write(directory.path(), &snapshot).unwrap();
|
||||
app.recovery.writing = true;
|
||||
let _ = app.update(Message::Menu(crate::model_menu::Event::Open(
|
||||
crate::model_menu::Request::object(id, iced::Point::ORIGIN),
|
||||
)));
|
||||
assert!(app.model_menu.is_some());
|
||||
let _ = app.update(Message::CheckpointWritten(app.canvas_document, Ok(())));
|
||||
assert!(
|
||||
!app.recovery.writing,
|
||||
"A passive completion cannot be swallowed by an open menu"
|
||||
);
|
||||
assert_eq!(app.recovery.active, Some(app.canvas_document));
|
||||
assert!(
|
||||
app.model_menu.is_some(),
|
||||
"Saving a checkpoint does not dismiss the menu"
|
||||
);
|
||||
assert_eq!(app.project().name, "Unsaved engineering");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discard_and_close_waits_for_the_late_checkpoint_then_retires_it() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().into());
|
||||
app.execute(Command::Rename(
|
||||
app.project().id,
|
||||
"Discarded on close".into(),
|
||||
));
|
||||
let snapshot = retained_snapshot(&app);
|
||||
let document = app.canvas_document;
|
||||
app.recovery.writing = true;
|
||||
let window = window::Id::unique();
|
||||
let _ = app.update(Message::Request(Pending::Close(window)));
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
|
||||
let closing = app.update(Message::Discard);
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(closing).is_none(),
|
||||
"The window must remain until its outstanding writer finishes"
|
||||
);
|
||||
assert_eq!(app.checkpoint_close, Some(window));
|
||||
assert!(app.busy);
|
||||
recovery::write(directory.path(), &snapshot).unwrap();
|
||||
assert!(recovery::read(directory.path(), document).is_ok());
|
||||
let close = app.update(Message::CheckpointWritten(document, Ok(())));
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(close).is_some(),
|
||||
"Close is emitted after checkpoint cleanup"
|
||||
);
|
||||
assert!(app.checkpoint_close.is_none());
|
||||
assert!(recovery::read(directory.path(), document).is_err());
|
||||
assert!(app.recovery.active.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn recent_and_recovery_navigation_respect_unsaved_decision() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let id = app.project().id;
|
||||
app.execute(Command::Rename(id, "Retained work".into()));
|
||||
let _ = app.update(Message::Home(home::Event::OpenRecent(PathBuf::from(
|
||||
"missing.arcadia",
|
||||
))));
|
||||
assert!(matches!(app.pending, Some(Pending::Recent)));
|
||||
assert!(!app.busy);
|
||||
assert_eq!(app.project().name, "Retained work");
|
||||
let _ = app.update(Message::Cancel);
|
||||
let recovery_id = Id::new_v4();
|
||||
let _ = app.update(Message::Home(home::Event::Recover(recovery_id)));
|
||||
assert!(matches!(app.pending,Some(Pending::Recover(id)) if id==recovery_id));
|
||||
assert!(!app.busy);
|
||||
}
|
||||
#[test]
|
||||
fn restored_copy_keeps_raw_property_and_requires_save_without_local_or_hosted_binding() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let selected = app.project().id;
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Not yet applied".into());
|
||||
let snapshot = recovery::Snapshot::capture(
|
||||
Id::new_v4(),
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
app.draft.as_ref(),
|
||||
&app.behavior,
|
||||
Some(PathBuf::from("original.arcadia")),
|
||||
selected,
|
||||
)
|
||||
.unwrap();
|
||||
let checkpoint_id = snapshot.id;
|
||||
app.recovery.entries.push(recovery::Entry {
|
||||
id: checkpoint_id,
|
||||
name: "Retained work".into(),
|
||||
origin: None,
|
||||
modified: snapshot.modified,
|
||||
});
|
||||
let _ = app.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
||||
assert!(app.path.is_none());
|
||||
assert!(app.disk_project.is_none());
|
||||
assert!(app.hosted.session.is_none());
|
||||
assert!(app.document.dirty());
|
||||
assert!(app.draft_dirty());
|
||||
assert_eq!(app.recovery.restored, Some(checkpoint_id));
|
||||
assert!(
|
||||
app.recovery.entries.is_empty(),
|
||||
"Home must not offer the copy currently being recovered again"
|
||||
);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Not yet applied"
|
||||
);
|
||||
assert_ne!(app.project().name, "Not yet applied");
|
||||
}
|
||||
#[test]
|
||||
fn stale_write_completion_cannot_recover_a_discarded_document() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::demo());
|
||||
app.recovery.directory = Some(directory.path().into());
|
||||
let old = app.canvas_document;
|
||||
let s = recovery::Snapshot::capture(
|
||||
old,
|
||||
&app.document,
|
||||
&app.source_workspace,
|
||||
None,
|
||||
&app.behavior,
|
||||
None,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap();
|
||||
app.reset(Project::empty(), None);
|
||||
recovery::write(directory.path(), &s).unwrap();
|
||||
app.checkpoint_written(old, Ok(()));
|
||||
assert!(recovery::read(directory.path(), old).is_err());
|
||||
assert!(app.recovery.active.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_checkpoint_does_not_replace_the_current_model() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let original = app.document.bundle().clone();
|
||||
let _ = app.checkpoint_loaded(Err("Damaged checkpoint".into()));
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(app.status.contains("Damaged"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::*;
|
||||
|
||||
fn complete_save(app: &mut App, path: PathBuf) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path,
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_receipt_then_one_undo_restores_meaning_and_saving_preserves_redo() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("semantic-accepted.arcadia");
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document.mark_recovered();
|
||||
let original = app.document.bundle().files[&app.document.bundle().entry].clone();
|
||||
app.execute(Command::Rename(
|
||||
app.project().id,
|
||||
"Accepted semantic change".into(),
|
||||
));
|
||||
let after = app.document.bundle().files[&app.document.bundle().entry].clone();
|
||||
complete_save(&mut app, path.clone());
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.path.as_ref(), Some(&path));
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), after);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle().entry, "semantic-accepted.arcadia");
|
||||
assert_eq!(
|
||||
app.document.bundle().files[&app.document.bundle().entry],
|
||||
original
|
||||
);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Save as did not insert a history step"
|
||||
);
|
||||
assert!(app.document.can_redo());
|
||||
complete_save(&mut app, path.clone());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
|
||||
assert!(
|
||||
app.document.can_redo(),
|
||||
"Saving an undone state preserves the future"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(
|
||||
app.document.bundle().files[&app.document.bundle().entry],
|
||||
after
|
||||
);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_preflight_rejects_historical_collision_before_starting_io() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("taken.arcadia");
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let base = app.document.bundle().clone();
|
||||
let mut included = base.clone();
|
||||
let text = included.files.get_mut(&included.entry).unwrap();
|
||||
text.insert_str(text.rfind('}').unwrap(), "\n include \"taken.arcadia\";\n");
|
||||
included
|
||||
.files
|
||||
.insert("taken.arcadia".into(), "// retained\n".into());
|
||||
app.document.apply_bundle(included).unwrap();
|
||||
app.document.apply_bundle(base).unwrap();
|
||||
app.sync_source();
|
||||
let before = app.document.snapshot();
|
||||
let task = app.save_to(path.clone());
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(!path.exists());
|
||||
assert!(!app.busy);
|
||||
assert!(app.status.contains("Undo/Redo"));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
let json = app.save_to(directory.path().join("taken.json"));
|
||||
assert!(
|
||||
iced_runtime::task::into_stream(json).is_some(),
|
||||
"JSON exports do not relocate source entries"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_as_late_receipt_preserves_newer_raw_source_draft_and_destination() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let entry = app.document.bundle().entry.clone();
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
directory.path().join("accepted.arcadia"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let raw = format!("{}\nunfinished {{", app.source_workspace.text());
|
||||
let cursor = app.source_workspace.files[&entry].cursor;
|
||||
app.source_workspace.edit(raw.clone(), cursor);
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert_eq!(app.document.bundle().entry, entry);
|
||||
assert!(app.path.is_none());
|
||||
assert!(app.dirty());
|
||||
assert!(app.status.contains("Newer edits"));
|
||||
}
|
||||
@@ -6,6 +6,41 @@ use crate::behavior::{
|
||||
};
|
||||
|
||||
impl App {
|
||||
/// A saved state view has one renderer and tool set: the behavior workspace.
|
||||
/// Run after navigation/adoption, never as a model or property edit. Keeping
|
||||
/// selection and its Draft intact also preserves unrelated raw properties.
|
||||
pub(super) fn route_state_diagram_workspace(&mut self) {
|
||||
if self.page != Page::Diagram {
|
||||
return;
|
||||
}
|
||||
let Some((diagram, machine, layer)) = self.active_diagram.and_then(|id| {
|
||||
let view = self.project().diagrams.get(&id)?;
|
||||
Some((id, view.projection.state_machine()?, view.layer))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
// Existing navigation guards own pending behavior forms. Never replace
|
||||
// their owner/camera if an exceptional caller leaves one on another page.
|
||||
if self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
self.layer = layer;
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(machine);
|
||||
self.behavior.diagram = behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
placement: self.project().diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.id == self.selected || node.element == self.selected)
|
||||
.map(|node| node.id),
|
||||
..Default::default()
|
||||
};
|
||||
self.selected_node = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.fit_state_diagram();
|
||||
}
|
||||
|
||||
/// The behavior view resolved independently of the current page. Capture
|
||||
/// this before selection/layer changes, while it still identifies the view
|
||||
/// whose camera and tool are retained during navigation.
|
||||
|
||||
@@ -89,6 +89,153 @@ fn assert_source_model_agree(app: &App) {
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_diagram_routes_constructor_and_source_open_to_the_existing_workspace_without_edits() {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let project = f.app.project().clone();
|
||||
let app = App::with_project(project.clone());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, diagram);
|
||||
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
|
||||
assert_eq!(app.behavior.diagram.tool, Tool::Select);
|
||||
assert_eq!(app.selected, project.id);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert!(!app.document.can_undo());
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("state-view.arcadia");
|
||||
let text = format!(
|
||||
"// Exact authored source must remain unchanged.\n{}",
|
||||
language::format(&project)
|
||||
);
|
||||
std::fs::write(&path, &text).unwrap();
|
||||
let mut reopened = App::with_project(Project::empty());
|
||||
send(
|
||||
&mut reopened,
|
||||
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
|
||||
);
|
||||
assert_eq!(reopened.page, Page::Behavior);
|
||||
assert_eq!(reopened.state_context().unwrap().diagram, diagram);
|
||||
assert_eq!(reopened.project(), &project);
|
||||
assert_eq!(
|
||||
reopened.document.bundle().files[&reopened.document.bundle().entry],
|
||||
text
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(path).unwrap(), text);
|
||||
assert!(!reopened.document.can_undo());
|
||||
assert_source_model_agree(&reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_diagram_routes_picker_and_reveal_but_explicit_architecture_does_not_bounce() {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
let second = state_edit::create_view(&project, f.machine).unwrap();
|
||||
project = prepare_command(&project, &second.command).unwrap().project;
|
||||
let transition = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
|
||||
let mut app = App::with_project(project.clone());
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(Choice {
|
||||
id: second.diagram,
|
||||
label: "Second state view".into(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, second.diagram);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, transition));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, diagram);
|
||||
assert_eq!(app.selected, transition);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, transition);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_source_model_agree(&app);
|
||||
|
||||
let architecture = project.ensure_diagram(Layer::Sa);
|
||||
let mut app = App::with_project(project);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "State view".into(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert!(app.state_context().is_none());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_diagram_routes_preserve_selected_raw_properties_and_pending_behavior_guards() {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut app = App::with_project(f.app.project().clone());
|
||||
send(&mut app, Message::Select(f.a));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/alias".into(), "invalid alias!".into()),
|
||||
);
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
assert!(!app.apply());
|
||||
let before = app.document.snapshot();
|
||||
// Exercise normalization itself with an existing raw draft, including callers
|
||||
// that have just adopted a view without a successful property Apply.
|
||||
app.page = Page::Diagram;
|
||||
app.route_state_diagram_workspace();
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "State view".into(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
|
||||
let mut app = App::with_project(f.app.project().clone());
|
||||
form(
|
||||
&mut app,
|
||||
states::Event::Start(f.machine, states::Tool::Transition, f.a),
|
||||
);
|
||||
form(
|
||||
&mut app,
|
||||
states::Event::Name("Pending behavior stays".into()),
|
||||
);
|
||||
let pending = app.behavior.recovery_snapshot().unwrap();
|
||||
let context = app.state_context().unwrap();
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "State view".into(),
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, context.diagram);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), pending);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
struct NavigationFixture {
|
||||
app: App,
|
||||
machine_a: Id,
|
||||
@@ -789,12 +936,15 @@ fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move(
|
||||
let f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
// One eligible machine makes the fallback deterministic. Start from a loaded
|
||||
// project with no selected behavior element and no explicitly chosen owner.
|
||||
// architecture view with no selected behavior element or chosen owner. A
|
||||
// state-only project now opens its saved view with an explicit owner.
|
||||
project
|
||||
.elements
|
||||
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
|
||||
let plan = state_edit::create_view(&project, f.machine).unwrap();
|
||||
let project = prepare_command(&project, &plan.command).unwrap().project;
|
||||
let mut project = prepare_command(&project, &plan.command).unwrap().project;
|
||||
let architecture = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&architecture).unwrap().projection = ProjectionKind::Allocation;
|
||||
let mut app = App::with_project(project);
|
||||
assert_eq!(app.selected, app.project().id);
|
||||
assert!(app.behavior.active_owner.is_none());
|
||||
@@ -917,7 +1067,11 @@ fn shared_semantic_states_keep_architecture_and_behavior_placements_independent(
|
||||
id: architecture,
|
||||
label: app.project().named(architecture).into(),
|
||||
};
|
||||
// The initial UUID-ordered view may be a state view, which correctly opens
|
||||
// Behavior. Enter Architecture explicitly before testing its selection.
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
send(&mut app, Message::Diagram(architecture_choice));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
send(&mut app, Message::Select(f.b));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected_node, Some(arch_b));
|
||||
|
||||
Reference in New Issue
Block a user