Compose saved Scenario authoring with current Text and input authority

This commit is contained in:
2026-09-23 00:25:20 -07:00
parent 5960563b23
commit 6f4dfc67b3
78 changed files with 14097 additions and 318 deletions
+110 -12
View File
@@ -1729,11 +1729,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.;
@@ -1872,7 +1893,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
svg.push_str("</g>");
}
svg.push_str("</svg>");
svg
Ok(svg)
}
#[cfg(test)]
@@ -1882,6 +1903,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();
@@ -1915,7 +2013,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)
@@ -1989,7 +2087,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>"))
@@ -2080,7 +2178,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)))));
}
@@ -2095,7 +2193,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\""));
}
@@ -2183,7 +2281,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)))));
}
@@ -2646,7 +2744,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
@@ -2706,7 +2804,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\""));