714 lines
29 KiB
Rust
714 lines
29 KiB
Rust
//! 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::Walkthrough => self.handle(Message::Request(Pending::Walkthrough)),
|
|
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()))
|
|
.map(|snapshot| snapshot.with_architecture(self.architecture.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.migration = self.document.compiled().decoded.migration.clone();
|
|
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
|
|
.behavior
|
|
.creation
|
|
.pending_layer()
|
|
.or_else(|| 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
|
|
};
|
|
if let Some(saved) = restored.architecture {
|
|
self.active_diagram = Some(saved.diagram);
|
|
self.layer = self.project().diagrams[&saved.diagram].layer;
|
|
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
|
self.page = Page::Diagram;
|
|
self.architecture = architecture_authoring::State::restore(self, saved).ok();
|
|
}
|
|
self.status = if let Some(report) = &self.migration {
|
|
format!(
|
|
"Recovered source {} as source {} in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it.",
|
|
report.from_schema, report.to_schema
|
|
)
|
|
} else {
|
|
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into()
|
|
};
|
|
let lint = self.request_lint();
|
|
if self
|
|
.architecture
|
|
.as_ref()
|
|
.is_some_and(architecture_authoring::State::inline)
|
|
{
|
|
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
|
|
} else if self.page == Page::Behavior && self.behavior.source_open() {
|
|
Task::batch([
|
|
lint,
|
|
iced::widget::operation::focus(behavior::source_draft::NAME_INPUT),
|
|
])
|
|
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
|
|
Task::batch([
|
|
lint,
|
|
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
|
|
])
|
|
} else {
|
|
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()));
|
|
}
|
|
|
|
#[test]
|
|
fn walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard() {
|
|
let mut app = App::with_project(Project::demo());
|
|
let accepted = app.document.snapshot();
|
|
let raw = format!(
|
|
"{}\nunfinished walkthrough draft",
|
|
app.source_workspace.text()
|
|
);
|
|
app.source_workspace
|
|
.edit(raw.clone(), app.source_editor.cursor());
|
|
let task = app.update(Message::Home(home::Event::Walkthrough));
|
|
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
|
|
assert!(!app.busy);
|
|
assert!(iced_runtime::task::into_stream(task).is_none());
|
|
assert_eq!(app.source_workspace.text(), raw);
|
|
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
|
let _ = app.update(Message::Escape);
|
|
assert!(app.pending.is_none());
|
|
assert_eq!(app.source_workspace.text(), 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()
|
|
}
|
|
|
|
async fn run_recovery_tasks(app: &mut App, task: Task<Message>) -> (usize, bool, bool) {
|
|
use iced::advanced::{Layout, layout, widget};
|
|
use iced::futures::StreamExt;
|
|
// Mount real inputs, with focus initially in another control. This
|
|
// verifies the returned operation targets the naming field by its ID.
|
|
let mut fields: iced::Element<'_, (), iced::Theme, ()> = iced::widget::column![
|
|
iced::widget::text_input("Engineering name", "Retained name")
|
|
.id("architecture-name")
|
|
.on_input(|_| ()),
|
|
iced::widget::text_input("Other control", "Other value")
|
|
.id("unrelated-field")
|
|
.on_input(|_| ())
|
|
]
|
|
.into();
|
|
let mut tree = widget::Tree::new(fields.as_widget());
|
|
let node = fields.as_widget_mut().layout(
|
|
&mut tree,
|
|
&(),
|
|
&layout::Limits::new(iced::Size::ZERO, iced::Size::new(320., 100.)),
|
|
);
|
|
tree.children[1]
|
|
.state
|
|
.downcast_mut::<iced::widget::text_input::State<()>>()
|
|
.focus();
|
|
let mut operations = 0;
|
|
let mut lint_completions = 0;
|
|
let mut stream = iced_runtime::task::into_stream(task).expect("Recovery must still lint");
|
|
while let Some(action) = stream.next().await {
|
|
match action {
|
|
iced_runtime::Action::Widget(mut operation) => {
|
|
operations += 1;
|
|
fields.as_widget_mut().operate(
|
|
&mut tree,
|
|
Layout::new(&node),
|
|
&(),
|
|
operation.as_mut(),
|
|
);
|
|
}
|
|
iced_runtime::Action::Output(message @ Message::Linted(_, _)) => {
|
|
lint_completions += 1;
|
|
let _ = app.update(message);
|
|
}
|
|
_ => panic!("Unexpected recovery task action"),
|
|
}
|
|
}
|
|
assert_eq!(
|
|
lint_completions, 1,
|
|
"Autofocus must not replace request_lint"
|
|
);
|
|
assert!(app.lint_running.is_none());
|
|
let focused = |index: usize| {
|
|
tree.children[index]
|
|
.state
|
|
.downcast_ref::<iced::widget::text_input::State<()>>()
|
|
.is_focused()
|
|
};
|
|
(operations, focused(0), focused(1))
|
|
}
|
|
|
|
fn architecture_recovery_app() -> App {
|
|
let mut app = App::with_project(Project::empty());
|
|
let _ = app.update(Message::AddDiagram);
|
|
let _ = app.update(Message::Architecture(architecture_authoring::Event::Start(
|
|
Kind::Entity,
|
|
Some(Position { x: 80., y: 90. }),
|
|
)));
|
|
let _ = app.update(Message::Architecture(architecture_authoring::Event::Name(
|
|
"Accepted team".into(),
|
|
)));
|
|
let _ = app.update(Message::Architecture(architecture_authoring::Event::Submit));
|
|
assert!(app.architecture.is_none());
|
|
app
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn checkpoint_inline_create_and_rename_restore_focus_without_changing_drafts_or_history()
|
|
{
|
|
use architecture_authoring::Event;
|
|
for rename in [false, true] {
|
|
let mut app = architecture_recovery_app();
|
|
let point = Position { x: 439., y: 328.2 };
|
|
let event = if rename {
|
|
Event::Rename(app.route_context().unwrap(), app.selected, point)
|
|
} else {
|
|
Event::Start(Kind::Actor, Some(point))
|
|
};
|
|
let _ = app.update(Message::Architecture(event));
|
|
let _ = app.update(Message::Architecture(Event::Name(
|
|
" Recovered engineering name 雨 ".into(),
|
|
)));
|
|
let saved = serde_json::to_value(&app.architecture.as_ref().unwrap().saved).unwrap();
|
|
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
|
|
let expected = snapshot.clone().restore().unwrap();
|
|
let checkpoint = snapshot.id;
|
|
let mut recovered = App::with_project(Project::empty());
|
|
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
|
assert_eq!(
|
|
run_recovery_tasks(&mut recovered, task).await,
|
|
(1, true, false)
|
|
);
|
|
assert_eq!(recovered.page, Page::Diagram);
|
|
assert!(recovered.architecture.as_ref().unwrap().inline());
|
|
assert_eq!(
|
|
serde_json::to_value(&recovered.architecture.as_ref().unwrap().saved).unwrap(),
|
|
saved
|
|
);
|
|
assert_eq!(recovered.project(), expected.document.project());
|
|
assert_eq!(recovered.document.bundle(), expected.document.bundle());
|
|
assert_eq!(recovered.document.revision(), expected.document.revision());
|
|
assert_eq!(recovered.document.can_undo(), expected.document.can_undo());
|
|
assert_eq!(recovered.document.can_redo(), expected.document.can_redo());
|
|
assert_eq!(
|
|
recovered.source_workspace.bundle(),
|
|
expected.workspace.bundle()
|
|
);
|
|
assert_eq!(recovered.recovery.restored, Some(checkpoint));
|
|
assert!(recovered.path.is_none());
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn checkpoint_noninline_contexts_keep_other_focus_and_still_lint() {
|
|
use architecture_authoring::Event;
|
|
for context in ["accepted", "armed", "picker", "property", "source"] {
|
|
let mut app = architecture_recovery_app();
|
|
match context {
|
|
"armed" => {
|
|
let _ = app.update(Message::Architecture(Event::Start(Kind::Actor, None)));
|
|
}
|
|
"picker" => {
|
|
let _ = app.update(Message::Architecture(Event::Existing(None)));
|
|
}
|
|
"property" => {
|
|
app.draft
|
|
.as_mut()
|
|
.unwrap()
|
|
.input("/name", "Unapplied property".into());
|
|
assert!(app.draft_dirty());
|
|
}
|
|
"source" => {
|
|
app.source_workspace
|
|
.edit("incomplete source 雨".into(), app.source_editor.cursor());
|
|
assert!(app.source_dirty());
|
|
}
|
|
_ => {}
|
|
}
|
|
let snapshot = retained_snapshot(&app).with_architecture(app.architecture.as_ref());
|
|
let expected = snapshot.clone().restore().unwrap();
|
|
let mut recovered = App::with_project(Project::empty());
|
|
let task = recovered.checkpoint_loaded(Ok(Arc::new(snapshot)));
|
|
assert_eq!(
|
|
run_recovery_tasks(&mut recovered, task).await,
|
|
(0, false, true),
|
|
"{context}"
|
|
);
|
|
assert!(
|
|
!recovered
|
|
.architecture
|
|
.as_ref()
|
|
.is_some_and(architecture_authoring::State::inline)
|
|
);
|
|
assert_eq!(recovered.project(), expected.document.project());
|
|
assert_eq!(recovered.document.bundle(), expected.document.bundle());
|
|
assert_eq!(recovered.document.revision(), expected.document.revision());
|
|
assert_eq!(
|
|
recovered.source_workspace.bundle(),
|
|
expected.workspace.bundle()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|