Save unverified allocation and repair work at user-requested pause
This commit is contained in:
@@ -416,6 +416,12 @@ impl Rect {
|
||||
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
||||
&& other.position.y + other.size.height <= self.position.y + self.size.height
|
||||
}
|
||||
fn overlaps(self, other: Self) -> bool {
|
||||
self.position.x < other.position.x + other.size.width
|
||||
&& other.position.x < self.position.x + self.size.width
|
||||
&& self.position.y < other.position.y + other.size.height
|
||||
&& other.position.y < self.position.y + self.size.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
@@ -583,6 +589,18 @@ pub fn place(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
// A performer may legitimately enclose the work, but its frozen feedback
|
||||
// label is painted above the candidate. Check their joint readability after
|
||||
// projection, including same-performer moves; never shift either rectangle.
|
||||
if presentation
|
||||
.target
|
||||
.is_some_and(|target| header.overlaps(target.label))
|
||||
{
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"The work header overlaps the performer label. Aim below or beside the label, then drag again."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
|
||||
@@ -1029,6 +1029,91 @@ fn target_presentation_must_name_the_destination_and_its_visible_frozen_header()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_performer_label_refuses_overlapping_work_header_and_allows_nearby_exact_move() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
f.p.elements.get_mut(&f.a).unwrap().name = "Receiver".into();
|
||||
let plan = semantic(&f, Destination::Performer(f.a));
|
||||
let mut context = presentation(&f.p, f.d, Some(f.a));
|
||||
// The actual frozen renderer bounds for Receiver at (100, 100).
|
||||
context.target.as_mut().unwrap().label = Rect {
|
||||
position: xy(110., 131.),
|
||||
size: Size { width: 88., height: 22. },
|
||||
};
|
||||
let label = context.target.unwrap().label;
|
||||
let counterexample = Rect {
|
||||
position: xy(120., 110.),
|
||||
size: Size { width: 224., height: 62. },
|
||||
};
|
||||
assert!(context.viewport.contains(label));
|
||||
assert!(context.viewport.contains(counterexample));
|
||||
assert!(label.contains(Rect {
|
||||
position: xy(134., 145.),
|
||||
size: Size { width: 1., height: 1. },
|
||||
}), "the formerly admitted work name begins underneath the label");
|
||||
let before = plan.project().clone();
|
||||
assert!(matches!(
|
||||
place(&plan, xy(120., 105.), context),
|
||||
Err(Refusal::LayoutRequired(message)) if message.contains("Aim below or beside")
|
||||
));
|
||||
assert_eq!(plan.project(), &before);
|
||||
|
||||
// A 50-unit lower pointer position clears the frozen label. The planner
|
||||
// preserves the user's snapped position instead of packing either card.
|
||||
let placed = place(&plan, xy(120., 155.), context).unwrap();
|
||||
assert_eq!(placed.diagram.nodes[&f.n].position, xy(120., 160.));
|
||||
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
||||
assert_eq!(validation::effective_performer(&after, f.w), Some(f.a));
|
||||
assert_eq!(after.diagrams[&f.d].nodes[&f.n].position, xy(120., 160.));
|
||||
assert_eq!(after.elements, f.p.elements);
|
||||
assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_label_readability_allows_touching_edges_but_refuses_positive_area_overlap() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let plan = semantic(&f, Destination::Performer(f.a));
|
||||
let mut context = presentation(&f.p, f.d, Some(f.a));
|
||||
context.target.as_mut().unwrap().label = Rect {
|
||||
position: xy(110., 131.),
|
||||
size: Size { width: 90., height: 19. },
|
||||
};
|
||||
for (position, allowed) in [
|
||||
(xy(190., 110.), false),
|
||||
(xy(200., 110.), true),
|
||||
(xy(210., 110.), true),
|
||||
(xy(120., 140.), false),
|
||||
(xy(120., 150.), true),
|
||||
(xy(120., 160.), true),
|
||||
] {
|
||||
let result = place(&plan, position, context);
|
||||
assert_eq!(result.is_ok(), allowed, "{position:?}: {result:?}");
|
||||
if let Ok(placed) = result {
|
||||
assert_eq!(placed.diagram.nodes[&f.n].position, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_performer_readability_preserves_snapped_noop_and_allocation_identity() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let a = f.a;
|
||||
allocation(&mut f, a);
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().nodes.get_mut(&f.n).unwrap().position = xy(150., 200.);
|
||||
let plan = semantic(&f, Destination::Performer(f.a));
|
||||
let context = presentation(&f.p, f.d, Some(f.a));
|
||||
assert_eq!(plan.project(), &f.p);
|
||||
let noop = place(&plan, xy(154., 200.), context).unwrap();
|
||||
assert!(finish(&plan, &f.p, &noop).unwrap().is_none());
|
||||
let moved = place(&plan, xy(160., 210.), context).unwrap();
|
||||
let after = roundtrip(&f.p, finish(&plan, &f.p, &moved).unwrap().unwrap());
|
||||
assert_eq!(after.relations, f.p.relations);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometry_refuses_new_overlap_bounds_and_invisible_headers_and_preserves_existing_overlap() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
|
||||
@@ -3060,11 +3060,17 @@ impl App {
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.path = Some(receipt.target);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.disk_project = Some(disk);
|
||||
}
|
||||
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
|
||||
}
|
||||
Err(error) => {
|
||||
if self.path.is_none()
|
||||
|| self.path.as_ref() == Some(&receipt.target)
|
||||
{
|
||||
self.document.invalidate_saved_baseline();
|
||||
}
|
||||
self.status = format!(
|
||||
"Disk recovery completed; files could not be reloaded: {error}"
|
||||
)
|
||||
@@ -3841,6 +3847,7 @@ impl App {
|
||||
self.canvas_document = Id::new_v4();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.show_source_file(false);
|
||||
@@ -4508,6 +4515,10 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
|
||||
#[path = "app/save_as_tests.rs"]
|
||||
mod save_as_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "app/repair_save_state_tests.rs"]
|
||||
mod repair_save_state_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod source_project_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -132,7 +132,7 @@ fn mounted_oa_sa_ordered_incoming_between_outgoing_are_exact_single_history_step
|
||||
let mut f = fixture(layer);
|
||||
let original = f.app.project().clone();
|
||||
let original_source = f.app.document.bundle().clone();
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
drag(&mut f, screen(xy(160., 190.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
let relation = f
|
||||
.app
|
||||
@@ -150,7 +150,7 @@ fn mounted_oa_sa_ordered_incoming_between_outgoing_are_exact_single_history_step
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.document.bundle(), &after_in);
|
||||
drag(&mut f, screen(xy(960., 140.)));
|
||||
drag(&mut f, screen(xy(960., 190.)));
|
||||
let reassigned = &f.app.project().relations[&relation.id];
|
||||
assert_eq!(reassigned.source(), f.b, "{}", f.app.status);
|
||||
assert_eq!(reassigned.name, relation.name);
|
||||
@@ -208,7 +208,7 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
|
||||
let input = &f.app.draft.as_ref().unwrap().inputs["/name"];
|
||||
let raw = input.raw.clone();
|
||||
let cursor = input.editor.cursor();
|
||||
let events = batch(&f.app, start(&f), screen(xy(160., 140.)));
|
||||
let events = batch(&f.app, start(&f), screen(xy(160., 190.)));
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(Message::WorkDrag(Event::Refused(..)))
|
||||
@@ -256,7 +256,7 @@ fn source_and_modal_admission_reject_prebuilt_clean_press_without_selecting() {
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
Some(screen(xy(160., 190.))),
|
||||
)));
|
||||
assert_eq!(f.app.selected, selection);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
@@ -302,7 +302,7 @@ fn modal_roundtrip_equal_fit_undo_resize_and_focus_retire_press_before_replay()
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
Some(screen(xy(160., 190.))),
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
@@ -321,7 +321,7 @@ fn stale_packets_do_not_cancel_a_newer_token_and_each_release_is_consumed_once()
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
old.stamp,
|
||||
old.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
Some(screen(xy(160., 190.))),
|
||||
)));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
@@ -330,7 +330,7 @@ fn stale_packets_do_not_cancel_a_newer_token_and_each_release_is_consumed_once()
|
||||
let release = Message::WorkDrag(Event::Release(
|
||||
new.stamp,
|
||||
new.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
Some(screen(xy(160., 190.))),
|
||||
));
|
||||
let _ = f.app.update(release.clone());
|
||||
let revision = f.app.document.revision();
|
||||
@@ -370,7 +370,7 @@ fn arrange_is_one_gesture_and_snapped_noop_preserves_redo_and_source() {
|
||||
#[test]
|
||||
fn outside_release_is_cancel_and_does_not_remove_an_assignment() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
drag(&mut f, screen(xy(160., 190.)));
|
||||
let before = f.app.document.bundle().clone();
|
||||
let revision = f.app.document.revision();
|
||||
drag(
|
||||
@@ -386,8 +386,8 @@ fn candidate_semantics_are_cached_per_destination_with_stable_ids_and_own_index(
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let p = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(p.clone())));
|
||||
let a = screen(xy(160., 140.));
|
||||
let b = screen(xy(960., 140.));
|
||||
let a = screen(xy(160., 190.));
|
||||
let b = screen(xy(960., 190.));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(a))));
|
||||
@@ -508,8 +508,8 @@ fn frozen_targets_use_half_open_semantic_priority_and_refuse_incomparable_overla
|
||||
fn release_uses_ordered_native_position_before_later_motion_in_same_batch() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let from = start(&f);
|
||||
let a = screen(xy(160., 140.));
|
||||
let b = screen(xy(960., 140.));
|
||||
let a = screen(xy(160., 190.));
|
||||
let b = screen(xy(960., 190.));
|
||||
let mut state = CanvasState::default();
|
||||
let canvas = canvas(&f.app);
|
||||
let events: Vec<_> = [
|
||||
@@ -603,7 +603,7 @@ fn source_refusal_is_atomic_and_a_new_gesture_can_retry_against_live_revision()
|
||||
f.app.document = crate::document::Document::from_bundle(before.clone(), limits).unwrap();
|
||||
f.app.sync();
|
||||
let snapshot = f.app.document.snapshot();
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
drag(&mut f, screen(xy(160., 190.)));
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
@@ -611,7 +611,7 @@ fn source_refusal_is_atomic_and_a_new_gesture_can_retry_against_live_revision()
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
f.app.document = crate::document::Document::from_bundle(before, Default::default()).unwrap();
|
||||
f.app.sync();
|
||||
drag(&mut f, screen(xy(160., 140.)));
|
||||
drag(&mut f, screen(xy(160., 190.)));
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a),
|
||||
@@ -656,7 +656,7 @@ fn mounted_role_only_outgoing_refusal_retains_placement_and_same_performer_creat
|
||||
.find(|t| t.element == f.a)
|
||||
.unwrap()
|
||||
.region;
|
||||
drag(&mut f, screen(xy(target.x + 45., target.y + 40.)));
|
||||
drag(&mut f, screen(xy(target.x + 45., target.y + 100.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
assert_eq!(f.app.project().relations, relations);
|
||||
assert!(
|
||||
@@ -686,7 +686,7 @@ fn mounted_uniform_parent_moves_hidden_placements_and_assigns_only_leaves() {
|
||||
.collapsed = true;
|
||||
replace(&mut f, project);
|
||||
let before = f.app.project().diagrams[&f.diagram].nodes.clone();
|
||||
drag(&mut f, screen(xy(960., 140.)));
|
||||
drag(&mut f, screen(xy(960., 190.)));
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
let after = &f.app.project().diagrams[&f.diagram].nodes;
|
||||
let dx = after[&f.node].position.x - before[&f.node].position.x;
|
||||
@@ -805,7 +805,7 @@ fn pending_behavior_draft_refuses_actual_work_press_and_equal_document_replaceme
|
||||
crate::behavior::Event::Creation(crate::behavior::creation::Event::Open(Layer::Oa)),
|
||||
);
|
||||
assert!(f.app.behavior.has_pending());
|
||||
let events = batch(&f.app, start(&f), screen(xy(160., 140.)));
|
||||
let events = batch(&f.app, start(&f), screen(xy(160., 190.)));
|
||||
assert!(matches!(
|
||||
events.first(),
|
||||
Some(Message::WorkDrag(Event::Refused(..)))
|
||||
@@ -823,7 +823,7 @@ fn pending_behavior_draft_refuses_actual_work_press_and_equal_document_replaceme
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(screen(xy(160., 140.))),
|
||||
Some(screen(xy(160., 190.))),
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
@@ -854,7 +854,7 @@ fn actual_canvas_origin_change_cancels_held_work_before_any_legacy_release() {
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
moved_bounds,
|
||||
mouse::Cursor::Available(screen(xy(160., 140.))),
|
||||
mouse::Cursor::Available(screen(xy(160., 190.))),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
.unwrap();
|
||||
@@ -953,3 +953,92 @@ fn toolbar_status_leaves_transformed_top_edge_frozen_target_label_readable() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oa_sa_label_overlap_refuses_preview_release_and_retries_without_partial_source_or_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (zoom, pan) in [
|
||||
(1., Vector::ZERO),
|
||||
(0.75, Vector::new(37., -15.)),
|
||||
(1.25, Vector::new(-30., 23.)),
|
||||
] {
|
||||
let mut f = fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = "Receiver".into();
|
||||
replace(&mut f, project);
|
||||
// Refusals must also preserve an existing redo branch.
|
||||
let _ = f.app.update(Message::Command(Command::Rename(f.work, "Temporary".into())));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = pan;
|
||||
f.app.fit_to_view = false;
|
||||
let screen = |world: Position| Point::new(
|
||||
bounds().x + world.x * zoom + pan.x,
|
||||
bounds().y + world.y * zoom + pan.y,
|
||||
);
|
||||
let from = screen(xy(140., 535.));
|
||||
let overlap = screen(xy(160., 140.));
|
||||
let clear = screen(xy(160., 190.));
|
||||
let accepted = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let snapshot = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut events = batch(&f.app, from, overlap);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.refused, "{}", feedback.text);
|
||||
assert!(feedback.text.contains("Aim below or beside"));
|
||||
assert_eq!(feedback.press.frozen.targets[feedback.targets[0]].element, f.a);
|
||||
assert_eq!(f.app.project(), &accepted);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.document.can_redo());
|
||||
let _ = f.app.update(release);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(f.app.project(), &accepted);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.document.can_redo());
|
||||
|
||||
// In a new gesture, moving out of the collision reuses its semantic
|
||||
// candidate and stable allocation identity; only geometry changes.
|
||||
let mut events = batch(&f.app, from, overlap);
|
||||
events.pop();
|
||||
send(&mut f.app, events);
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
let p = active.press.clone();
|
||||
let semantic = active.cache[0].1.as_ref().unwrap().clone();
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(p.stamp, p.token, Some(clear))));
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert!(Arc::ptr_eq(&semantic, &preview.semantic));
|
||||
assert_eq!(preview.placement.diagram.nodes[&f.node].position, xy(120., 160.));
|
||||
assert_eq!(preview.placement.view, projection::project_diagram_with_index(
|
||||
semantic.project(), &preview.placement.diagram, semantic.index(),
|
||||
));
|
||||
let mut expected = semantic.project().clone();
|
||||
expected.diagrams.insert(f.diagram, preview.placement.diagram.clone());
|
||||
assert_eq!(f.app.project(), &accepted);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(p.stamp, p.token, Some(clear))));
|
||||
assert_eq!(f.app.project(), &expected, "{}", f.app.status);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
assert!(f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
let after = f.app.document.bundle().clone();
|
||||
assert_eq!(oa_core::source_project::compile_bundle(&after, Default::default()).unwrap().decoded.project, expected);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &accepted);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
use super::*;
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn open(app: &mut App, path: &std::path::Path) {
|
||||
let _ = app.update(Message::Loaded(Ok(Some(
|
||||
read_snapshot(path.to_owned()).unwrap(),
|
||||
))));
|
||||
}
|
||||
|
||||
fn edit(app: &mut App, text: &str) {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text.into())),
|
||||
)));
|
||||
}
|
||||
|
||||
fn save(app: &mut App, path: &std::path::Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
|
||||
for extra in [
|
||||
" include \"missing.arcadia\";\r\n",
|
||||
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
|
||||
] {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, &invalid).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
assert!(!app.dirty());
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
open(&mut app, &second_path);
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
edit(&mut app, VALID);
|
||||
assert!(app.apply_source(), "{}", app.status);
|
||||
assert!(app.source_workspace.applied);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"Equal content creates no history step"
|
||||
);
|
||||
assert!(app.document.dirty());
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
let close = window::Id::unique();
|
||||
let task = app.update(Message::Request(Pending::Close(close)));
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
|
||||
let _ = app.update(Message::Cancel);
|
||||
save(&mut app, &second_path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
|
||||
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let before = app.document.snapshot();
|
||||
let invalid = format!("{VALID} unfinished {{");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
open(&mut app, &path);
|
||||
let repaired = format!("{VALID}// Repaired rationale\r\n");
|
||||
edit(&mut app, &repaired);
|
||||
assert!(app.apply_source());
|
||||
assert!(app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
app.dirty(),
|
||||
"Old saved bytes are not saved at the repair destination"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
|
||||
|
||||
std::fs::write(&path, "external edit").unwrap();
|
||||
let failed = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.clone(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap_err();
|
||||
let _ = app.update(Message::Saved(Err(failed)));
|
||||
assert!(app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.dirty());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_equal_content_at_distinct_destinations_stays_clean() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&second_path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let before = app.document.bundle().clone();
|
||||
open(&mut app, &second_path);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.path.as_ref(), Some(&second_path));
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(app.hosted_label(), "All changes saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let root = second.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let before = VALID.replace("Repair 救援", "Before 救援");
|
||||
std::fs::write(&first_path, VALID).unwrap();
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), &before).unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &first_path);
|
||||
let accepted = app.document.snapshot();
|
||||
open(&mut app, &path);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert!(app.apply_source());
|
||||
assert!(
|
||||
!app.dirty(),
|
||||
"Current bytes still match disk before rollback"
|
||||
);
|
||||
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(!journal.exists());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_dirty());
|
||||
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
|
||||
assert_eq!(app.hosted_label(), "Unsaved changes");
|
||||
save(&mut app, &path);
|
||||
assert!(!app.dirty());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
|
||||
for (current_destination, failed_reload) in
|
||||
[(true, false), (false, false), (true, true), (false, true)]
|
||||
{
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
std::fs::write(&path, VALID).unwrap();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
open(&mut app, &path);
|
||||
let accepted = app.document.snapshot();
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.after"), VALID).unwrap();
|
||||
open(&mut app, &path);
|
||||
assert!(app.apply_source());
|
||||
assert!(!app.dirty());
|
||||
let destination = tempfile::tempdir().unwrap();
|
||||
let saved_elsewhere = destination
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
if !current_destination {
|
||||
save(&mut app, &saved_elsewhere);
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
}
|
||||
let mut receipt = recover_snapshot(path.clone()).unwrap();
|
||||
assert!(
|
||||
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
|
||||
"Missing entry reloads as an empty repair workspace"
|
||||
);
|
||||
if failed_reload {
|
||||
// Separately exercise the adapter's successful-recovery/failed-read
|
||||
// boundary; first-save rollback itself does not cause a read error.
|
||||
receipt.disk = Err("Injected post-recovery reload failure".into());
|
||||
}
|
||||
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!path.exists());
|
||||
assert!(!journal.exists());
|
||||
assert!(app.pending_recovery.is_none());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.dirty(), current_destination);
|
||||
if current_destination {
|
||||
let _ = app.update(Message::Request(Pending::New));
|
||||
assert!(matches!(app.pending, Some(Pending::New)));
|
||||
} else {
|
||||
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
|
||||
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ pub struct Document {
|
||||
past: Vec<HistoryEntry>,
|
||||
future: Vec<HistoryEntry>,
|
||||
current_bytes: usize,
|
||||
saved: Arc<SourceBundle>,
|
||||
saved: Option<Arc<SourceBundle>>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
@@ -125,7 +125,7 @@ impl Document {
|
||||
current_bytes,
|
||||
past: Vec::new(),
|
||||
future: Vec::new(),
|
||||
saved: Arc::new(bundle),
|
||||
saved: Some(Arc::new(bundle)),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
@@ -156,7 +156,7 @@ impl Document {
|
||||
}
|
||||
|
||||
pub fn dirty(&self) -> bool {
|
||||
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
|
||||
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
|
||||
}
|
||||
|
||||
/// Recovered work is a detached copy until explicitly saved by its user.
|
||||
@@ -181,10 +181,24 @@ impl Document {
|
||||
/// Records the bytes actually saved, even when later edits remain current.
|
||||
/// The I/O adapter owns document identity and save completion ordering.
|
||||
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
|
||||
self.saved = Arc::new(snapshot.bundle.clone());
|
||||
self.saved = Some(Arc::new(snapshot.bundle.clone()));
|
||||
self.recovered_unsaved = false;
|
||||
}
|
||||
|
||||
/// Bind an installed or recovered repair destination to source read there.
|
||||
/// These bytes may be invalid or differ from the retained accepted model,
|
||||
/// but must replace the previous disk baseline. Accepted history is retained;
|
||||
/// applying or undoing to old saved content does not save this destination.
|
||||
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
|
||||
self.saved = Some(Arc::new(source));
|
||||
}
|
||||
|
||||
/// Recovery changed the current destination, but its source could not be
|
||||
/// reloaded. No accepted or historical snapshot is known to be saved there.
|
||||
pub(crate) fn invalidate_saved_baseline(&mut self) {
|
||||
self.saved = None;
|
||||
}
|
||||
|
||||
/// Check a source Save as before writing any files. A retained included file
|
||||
/// must never be displaced by the new entry, including on a later Undo.
|
||||
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
|
||||
|
||||
@@ -188,7 +188,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose New in the top bar. Use Project properties to name the model and describe its purpose. Apply changes commits the property draft.",
|
||||
"On Architecture, choose Create architecture view. Architecture · allocated work shows work inside its performer.",
|
||||
"Expand the diagram's side palette to see tool names. Choose Entity, click its position in the diagram, enter Rescue coordinator and press Enter. Escape cancels the draft. Use Actor for an external participant such as a Vessel in distress. Right-clicking a blank area also offers Create Entity here.",
|
||||
"Right-click Rescue coordinator and choose Add activity. Enter Assess incident and press Enter to create the activity and its performer allocation together. Ordinary Activity creation in the palette leaves work unallocated; use + Relationship with Allocation to assign existing work. The allocated-work view shows work inside its performer.",
|
||||
"Right-click Rescue coordinator and choose Add activity. Enter Assess incident and press Enter to create the activity and its performer allocation together. Ordinary Activity creation in the palette leaves work unallocated. In the allocated-work view, drag existing work into exposed space inside its performer to assign it; the highlighted performer and status explain the proposed change.",
|
||||
"Use Add existing to place an element that is already in the model. The initial choices match this view; no search is required. Include already shown offers Locate for elements already placed here.",
|
||||
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
|
||||
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
|
||||
@@ -263,7 +263,7 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Choose the Operational or System perspective. Create participants using Entity/Actor in OA or System/System actor in SA.",
|
||||
"In the diagram's side palette, choose Activity in OA or Function in SA, click its position, enter a name and press Enter. Escape cancels without creating an element. Select a card and press F2 to rename it in place. For semantic decomposition, use Model explorer, select existing work and use + In selected to create smaller work units.",
|
||||
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. To assign existing leaf work, use + Relationship with Allocation from the participant to the work. Model explorer also offers + Allocated when a participant and the appropriate work kind are selected.",
|
||||
"Right-click a participant and choose Add activity or Add function to create new work and its allocation together. In Architecture · allocated work, drag existing work into a performer to assign it, between performers to reassign it, or onto empty canvas to remove its direct assignment. + Relationship with Allocation and Model explorer's + Allocated provide explicit alternatives.",
|
||||
"Choose Architecture · allocated work to review responsibilities. Choose Functional dataflow to review the activity/function hierarchy and its exchanges independently.",
|
||||
],
|
||||
),
|
||||
@@ -289,7 +289,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.",
|
||||
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset selective visibility category categories coverage process context duplicate copy items",
|
||||
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange next drag Alt+A auto-layout fit fold hide zoom resize routing connection bend waypoint detour label reset selective visibility category categories coverage process context duplicate copy items",
|
||||
sections: &[
|
||||
explain(
|
||||
"Three things to distinguish",
|
||||
@@ -309,7 +309,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
|
||||
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
|
||||
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
|
||||
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
|
||||
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
|
||||
"Select a diagram node to access Placement, Fold / unfold and Hide. Hide removes its view placement; Delete from model removes model records and requires review of the affected references.",
|
||||
],
|
||||
),
|
||||
@@ -317,6 +317,14 @@ fn article(topic: Topic) -> Article {
|
||||
"Keep diagrams readable",
|
||||
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
|
||||
),
|
||||
walkthrough(
|
||||
"Move work between performers",
|
||||
&[
|
||||
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space. Read the highlighted destination and status before releasing; release outside the canvas or press Escape to cancel.",
|
||||
"The move preserves work ownership and identities. A uniformly assigned work group moves its leaves together; a group distributed across performers asks you to choose a leaf. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
|
||||
"Make room when headers, labels or other cards would overlap. Move the view or zoom in if the work and destination are not readable. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Save a focused exchange view",
|
||||
&[
|
||||
@@ -336,7 +344,7 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
|
||||
Reference in New Issue
Block a user