fix: preserve migration context and reject unsupported SVG exports
This commit is contained in:
@@ -68,6 +68,11 @@ pub(super) fn upgrade(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
if project.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
},
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3693,6 +3693,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::Export => {
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
if !self.project().diagrams.contains_key(&diagram) {
|
||||
self.status = crate::diagram::ExportError::MissingDiagram(diagram).to_string();
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply() {
|
||||
return self.choose_file(FileOperation::Export);
|
||||
}
|
||||
@@ -4138,7 +4146,13 @@ impl App {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let svg = crate::diagram::export_svg(self.project(), diagram);
|
||||
let svg = match crate::diagram::export_svg(self.project(), diagram) {
|
||||
Ok(svg) => svg,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.busy = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
@@ -5657,6 +5671,82 @@ mod saved_scenario_dispatch_tests {
|
||||
(app, architecture, saved, f.owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_export_schedules_no_write_and_preserves_draft_history_and_busy_state() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep export draft".into()));
|
||||
assert!(app.draft_dirty());
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("arcadia-rejected-export-{}.svg", Id::new_v4()));
|
||||
std::fs::write(&path, b"existing export").unwrap();
|
||||
for target in [Some(saved), Some(Id::new_v4()), None] {
|
||||
for busy in [false, true] {
|
||||
app.active_diagram = target;
|
||||
app.busy = busy;
|
||||
let before = app.document.bundle().clone();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
);
|
||||
let view = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
);
|
||||
let task = app.export_to(path.clone());
|
||||
assert_eq!(task.units(), 0, "rejection must not schedule a write");
|
||||
assert_eq!(app.busy, busy);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo()
|
||||
),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan
|
||||
),
|
||||
view
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing export");
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_export_target_does_not_apply_pending_properties_or_open_dialog() {
|
||||
for target in [None, Some(Id::new_v4())] {
|
||||
let (mut app, _, _, _) = fixture();
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied export draft".into(),
|
||||
));
|
||||
app.active_diagram = target;
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let task = app.handle(Message::Export);
|
||||
assert_eq!(task.units(), 0);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_saved_picker_and_reveal_do_not_apply_pending_properties() {
|
||||
for reveal in [false, true] {
|
||||
|
||||
@@ -1594,11 +1594,32 @@ fn hex(c: Color) -> String {
|
||||
(c.b * 255.) as u8
|
||||
)
|
||||
}
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExportError {
|
||||
MissingDiagram(Id),
|
||||
UnsupportedScenario(Id),
|
||||
}
|
||||
impl std::fmt::Display for ExportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingDiagram(id) => write!(f, "Cannot export: diagram {id} no longer exists."),
|
||||
Self::UnsupportedScenario(_) => {
|
||||
f.write_str("SVG export of saved Scenario views is not available yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ExportError {}
|
||||
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
|
||||
let d = p
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or(ExportError::MissingDiagram(diagram))?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ExportError::UnsupportedScenario(diagram));
|
||||
}
|
||||
let view = project_view(p, diagram);
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
return String::new();
|
||||
};
|
||||
let routed = route_diagram(p, d, &view);
|
||||
let left = routed.bounds.position.x - 30.;
|
||||
let top = routed.bounds.position.y - 30.;
|
||||
@@ -1737,7 +1758,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1747,6 +1768,83 @@ mod pointer_tests;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn svg_rejects_saved_scenario_with_absent_or_authored_layout_without_mutation() {
|
||||
let f = crate::behavior::scenario::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
d.projection = ProjectionKind::Scenario { scenario: f.owner };
|
||||
d.nodes.clear();
|
||||
d.routes.clear();
|
||||
for layout in [
|
||||
None,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: 240.,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.call,
|
||||
},
|
||||
send_y: 180.,
|
||||
}],
|
||||
})),
|
||||
] {
|
||||
p.diagrams.get_mut(&diagram).unwrap().layout = layout;
|
||||
let admitted = execute_batch(&p, &[]).expect("valid saved Scenario fixture");
|
||||
assert_eq!(admitted, p);
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
export_svg(&p, diagram),
|
||||
Err(ExportError::UnsupportedScenario(diagram))
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_rejects_missing_diagram_without_mutation() {
|
||||
let p = Project::demo();
|
||||
let missing = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
export_svg(&p, missing),
|
||||
Err(ExportError::MissingDiagram(missing))
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_accepts_populated_and_empty_architecture_views() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.projection.is_architecture() && !d.nodes.is_empty())
|
||||
.unwrap()
|
||||
.id;
|
||||
let before = p.clone();
|
||||
let svg = export_svg(&p, diagram).expect("populated architecture");
|
||||
assert!(svg.starts_with("<svg "));
|
||||
assert!(svg.contains("<text "));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert_eq!(p, before);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
d.projection = ProjectionKind::Overview;
|
||||
d.nodes.clear();
|
||||
d.routes.clear();
|
||||
assert_eq!(execute_batch(&p, &[]).expect("valid empty architecture"), p);
|
||||
let before = p.clone();
|
||||
let svg = export_svg(&p, diagram).expect("empty architecture is supported");
|
||||
assert!(svg.contains("viewBox=\"-30 -30 60 60\""));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
|
||||
let p = Project::demo();
|
||||
@@ -1780,7 +1878,7 @@ mod tests {
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let title = format!("<title>{}</title>", escape(&boundary.name));
|
||||
let edge_path = svg
|
||||
.split(&title)
|
||||
@@ -1854,7 +1952,7 @@ mod tests {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let group = svg
|
||||
.split("<g>")
|
||||
.find(|g| g.contains(">Grouped work</text>"))
|
||||
@@ -1944,7 +2042,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = project_view(&p, diagram);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
for edge in &view.edges {
|
||||
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
|
||||
}
|
||||
@@ -1959,7 +2057,7 @@ mod tests {
|
||||
p.place(d, a, Position { x: 0., y: 0. });
|
||||
p.place(d, b, Position { x: 400., y: 0. });
|
||||
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
|
||||
let svg = export_svg(&p, d);
|
||||
let svg = export_svg(&p, d).expect("supported diagram");
|
||||
assert!(svg.contains("«include»</text>"));
|
||||
assert!(svg.contains("stroke-dasharray=\"6 4\""));
|
||||
}
|
||||
@@ -2046,7 +2144,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(moved.position.x, port.position.x + 100.);
|
||||
assert_eq!(moved.position.y, port.position.y + 70.);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
for port in &view.ports {
|
||||
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
|
||||
}
|
||||
@@ -2503,7 +2601,7 @@ mod tests {
|
||||
});
|
||||
let view = project_view(&p, diagram);
|
||||
let geometry = route_diagram(&p, &p.diagrams[&diagram], &view);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let route = &geometry.edges[&edge];
|
||||
let path = route
|
||||
.points
|
||||
@@ -2563,7 +2661,7 @@ mod tests {
|
||||
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
|
||||
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
|
||||
assert_eq!(ends.len(), 2);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(svg.contains("parent [0..1]</text>"));
|
||||
assert!(svg.contains("children [0..*]</text>"));
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
|
||||
@@ -27,7 +27,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
validate_candidate(&p).unwrap();
|
||||
let initial = p.clone();
|
||||
let path_key = format!("<path data-edge=\"{edge}\"");
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let edge_path = svg
|
||||
.split(&path_key)
|
||||
.nth(1)
|
||||
@@ -46,7 +46,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.unwrap()
|
||||
.view_options
|
||||
.exchange_labels = ExchangeLabels::Items;
|
||||
let items_svg = export_svg(&p, diagram);
|
||||
let items_svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(items_svg.contains("Data & status"));
|
||||
let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
@@ -54,7 +54,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
unreachable!()
|
||||
};
|
||||
*oriented = true;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(
|
||||
svg.split(&path_key)
|
||||
.nth(1)
|
||||
@@ -71,7 +71,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert!(
|
||||
!export_svg(&p, diagram)
|
||||
.expect("supported diagram")
|
||||
.contains(&path_key)
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
@@ -83,7 +87,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert!(
|
||||
!export_svg(&p, diagram)
|
||||
.expect("supported diagram")
|
||||
.contains(&path_key)
|
||||
);
|
||||
assert_eq!(p.diagrams[&diagram].nodes, placements);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user