Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21977fca6 | ||
|
|
6018ae30b9 | ||
|
|
c7065db76e | ||
|
|
6520a89dc0 | ||
|
|
3a7221b80c | ||
|
|
ac69f555af | ||
|
|
603a59c178 | ||
|
|
0341bca846 | ||
|
|
715036e132 | ||
|
|
a9cd364eac |
+4
-14
@@ -1,4 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.lock text eol=lf
|
||||
@@ -12,12 +13,6 @@
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.zip binary
|
||||
harness/scenario-selection-evidence-20260924/** -text whitespace=cr-at-eol
|
||||
harness/scenario-group-spacing-evidence-20260924/** -text whitespace=cr-at-eol
|
||||
harness/scenario-reorder-interaction-evidence-20260924/** -text whitespace=cr-at-eol
|
||||
harness/scenario-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
harness/scenario-removal-followup-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/process-removal-removed-v2-20260925.arcadia -text whitespace=-blank-at-eol
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
@@ -96,11 +91,6 @@ harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-bl
|
||||
# Preserve the pinned Iced dependency bytes and upstream manifest.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve exact Process removal review and portable evidence bytes.
|
||||
harness/process-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve exact saved-view planner review and portable evidence bytes.
|
||||
harness/saved-group-removal-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve exact saved-view application check records.
|
||||
harness/saved-group-removal-app-evidence-20260925/** -text whitespace=cr-at-eol
|
||||
# Final overrides preserve original validation bytes after generic text rules.
|
||||
harness/experiments/navigation-validation-20261002/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/reviews/navigation-validation-packet-20261002/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
@@ -340,6 +340,18 @@ pub(crate) fn diagnostic(source: &str, error: Error) -> Diagnostic {
|
||||
},
|
||||
}
|
||||
}
|
||||
pub(crate) fn diagnostic_with_issue(
|
||||
source: &str,
|
||||
error: Error,
|
||||
issue: Option<validation::Issue>,
|
||||
) -> Diagnostic {
|
||||
let mut result = diagnostic(source, error);
|
||||
if let Some(issue) = issue {
|
||||
result.object_id = issue.element;
|
||||
result.field = issue.field;
|
||||
}
|
||||
result
|
||||
}
|
||||
fn reserved(word: &str) -> bool {
|
||||
crate::reserved_alias(word)
|
||||
|| declaration_kind(word).is_some()
|
||||
@@ -622,6 +634,7 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
||||
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
|
||||
return v2_source::parse(source, tokens, limits);
|
||||
}
|
||||
let mut migration_issue = None;
|
||||
Parser {
|
||||
source,
|
||||
tokens,
|
||||
@@ -629,8 +642,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
||||
value_depth: 0,
|
||||
limits,
|
||||
}
|
||||
.document()
|
||||
.map_err(|e| vec![diagnostic(source, e)])
|
||||
.document(&mut migration_issue)
|
||||
.map_err(|e| vec![diagnostic_with_issue(source, e, migration_issue)])
|
||||
}
|
||||
fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
let mut offset = 0;
|
||||
@@ -1449,7 +1462,10 @@ impl Parser<'_> {
|
||||
},
|
||||
}
|
||||
}
|
||||
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
||||
fn document(
|
||||
&mut self,
|
||||
migration_issue: &mut Option<validation::Issue>,
|
||||
) -> Result<ParsedDocument, Error> {
|
||||
let unit = self.unit(true, false)?;
|
||||
let header = unit.header.expect("entry header");
|
||||
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
|
||||
@@ -1476,9 +1492,14 @@ impl Parser<'_> {
|
||||
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
||||
);
|
||||
}
|
||||
let decoded =
|
||||
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
||||
.map_err(|(_, error)| error)?;
|
||||
let decoded = resolve_declarations(
|
||||
header.project,
|
||||
&unit.declarations,
|
||||
&aliases,
|
||||
self.limits,
|
||||
migration_issue,
|
||||
)
|
||||
.map_err(|(_, error)| error)?;
|
||||
Ok(ParsedDocument {
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
@@ -1682,6 +1703,7 @@ pub(crate) fn resolve_declarations(
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
limits: ParseLimits,
|
||||
migration_issue: &mut Option<validation::Issue>,
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let mut legacy_elements = serde_json::Map::new();
|
||||
@@ -1989,6 +2011,38 @@ pub(crate) fn resolve_declarations(
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
// These frozen admission checks formerly ran in final validation.
|
||||
// Keep their diagnostic code and reference location after moving
|
||||
// them before schema normalization.
|
||||
if let crate::persistence::FileError::Model { issues, .. } = &error
|
||||
&& let Some(issue) = issues.first()
|
||||
&& let Some(code) = match issue.code.as_str() {
|
||||
"diagram_layer" => Some("diagram_layer"),
|
||||
"view_options" => Some("view_options"),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let index = issue
|
||||
.element
|
||||
.and_then(|id| declarations.iter().position(|d| d.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
let offset = declarations.get(index).map_or(0, |d| {
|
||||
issue
|
||||
.field
|
||||
.as_ref()
|
||||
.and_then(|field| d.field_spans.get(field))
|
||||
.map_or(d.offset, |span| span.start)
|
||||
});
|
||||
*migration_issue = Some(issue.clone());
|
||||
return (
|
||||
index,
|
||||
Error {
|
||||
code,
|
||||
offset,
|
||||
message: issue.message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
(
|
||||
usize::MAX,
|
||||
Error {
|
||||
|
||||
@@ -69,8 +69,8 @@ impl Project4 {
|
||||
}
|
||||
crate::persistence::check_pre6_relations(self.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
let mut project = Project {
|
||||
schema_version: self.schema_version,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
@@ -84,7 +84,11 @@ impl Project4 {
|
||||
.into_iter()
|
||||
.map(|(id, trace)| (id, trace.into()))
|
||||
.collect(),
|
||||
})
|
||||
};
|
||||
// Schema3's adapter also enters here, without calling `finish`.
|
||||
crate::persistence::check_pre7_diagram_admission(&project)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +108,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
// Source4 enters here directly rather than through `Project4`.
|
||||
crate::persistence::check_pre7_diagram_admission(&project)?;
|
||||
crate::persistence::check_pre6_relations(project.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(project.elements.values())?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
|
||||
@@ -341,6 +341,50 @@ pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), Fil
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Frozen sources 3–6 admit opposite-layer placements and visibility references
|
||||
/// only in Traceability. Check before migration discards the advertised version;
|
||||
/// current ordinary projections may retain these records as latent presentation.
|
||||
pub(crate) fn check_pre7_diagram_admission(project: &Project) -> Result<(), FileError> {
|
||||
let mut issues = Vec::new();
|
||||
for diagram in project.diagrams.values() {
|
||||
let option_issues = if diagram.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(project, &diagram.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(project, diagram.layer, &diagram.view_options)
|
||||
};
|
||||
for issue in option_issues {
|
||||
validation::error(
|
||||
&mut issues,
|
||||
diagram.id,
|
||||
"view_options",
|
||||
issue.field,
|
||||
issue.message,
|
||||
);
|
||||
}
|
||||
for node in diagram.nodes.values() {
|
||||
if !project.elements.get(&node.element).is_some_and(|element| {
|
||||
diagram.projection == ProjectionKind::Traceability
|
||||
|| element.layer() == diagram.layer
|
||||
}) {
|
||||
validation::error(
|
||||
&mut issues,
|
||||
node.id,
|
||||
"diagram_layer",
|
||||
"element",
|
||||
"Diagram nodes reference existing elements in the selected layer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(issue) = issues.into_iter().next() {
|
||||
return Err(FileError::Model {
|
||||
message: issue.message.clone(),
|
||||
issues: vec![issue],
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v5(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
@@ -348,6 +392,7 @@ pub(crate) fn migrate_v5(
|
||||
if project.schema_version != 5 {
|
||||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||||
}
|
||||
check_pre7_diagram_admission(&project)?;
|
||||
check_pre6_relations(project.relations.values())?;
|
||||
check_pre6_elements(project.elements.values())?;
|
||||
for baseline in project
|
||||
@@ -420,6 +465,7 @@ pub(crate) fn migrate_v6(
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
check_pre7_diagram_admission(&project)?;
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
|
||||
@@ -85,6 +85,7 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
||||
.filter(|n| {
|
||||
p.elements.get(&n.element).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& (d.projection == ProjectionKind::Traceability || e.layer() == d.layer)
|
||||
&& d.projection.includes(e.kind())
|
||||
&& d.projection
|
||||
.state_machine()
|
||||
|
||||
@@ -589,6 +589,17 @@ fn apply_command(
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(missing)?;
|
||||
if source != *diagram {
|
||||
let destination = &p.diagrams[diagram];
|
||||
let element = p.diagrams[&source].nodes[node].element;
|
||||
if destination.projection != ProjectionKind::Traceability
|
||||
&& p.elements
|
||||
.get(&element)
|
||||
.is_none_or(|e| e.layer() != destination.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Move the placement to a view in its layer or a traceability view.".into(),
|
||||
));
|
||||
}
|
||||
let placement = p
|
||||
.diagrams
|
||||
.get_mut(&source)
|
||||
|
||||
@@ -573,16 +573,26 @@ fn compile_once(
|
||||
span,
|
||||
}),
|
||||
};
|
||||
let decoded =
|
||||
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: language::diagnostic(&bundle.files[path], error),
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let mut migration_issue = None;
|
||||
let decoded = language::resolve_declarations(
|
||||
header.project,
|
||||
&declarations,
|
||||
&aliases,
|
||||
limits.model,
|
||||
&mut migration_issue,
|
||||
)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: language::diagnostic_with_issue(
|
||||
&bundle.files[path],
|
||||
error,
|
||||
migration_issue,
|
||||
),
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
|
||||
let errors: Vec<_> = diagnostics
|
||||
.iter()
|
||||
|
||||
@@ -620,11 +620,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
crate::scenario_layout::validate_diagram(p, d, &mut out);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(p, d.layer, &d.view_options)
|
||||
};
|
||||
// Ordinary projections are reversible filters over the same authored view.
|
||||
// Retain opposite-layer visibility choices when leaving Traceability.
|
||||
let option_issues =
|
||||
if d.projection.state_machine().is_none() && d.projection.scenario().is_none() {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(p, d.layer, &d.view_options)
|
||||
};
|
||||
for issue in option_issues {
|
||||
error(&mut out, d.id, "view_options", issue.field, issue.message);
|
||||
}
|
||||
@@ -798,8 +801,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
);
|
||||
}
|
||||
match p.elements.get(&n.element) {
|
||||
Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => {
|
||||
}
|
||||
Some(e)
|
||||
if (d.projection.state_machine().is_none()
|
||||
&& d.projection.scenario().is_none())
|
||||
|| e.layer() == d.layer => {}
|
||||
_ => error(
|
||||
&mut out,
|
||||
n.id,
|
||||
|
||||
@@ -218,7 +218,9 @@ pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec
|
||||
validate_options_in(p, Some(layer), options)
|
||||
}
|
||||
|
||||
/// Traceability views admit references from either architecture layer.
|
||||
/// Ordinary saved views retain references from either architecture layer so
|
||||
/// projection changes preserve authored visibility choices. Projection decides
|
||||
/// which layer is visible; typed State/Scenario views use layer-scoped validation.
|
||||
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
|
||||
validate_options_in(p, None, options)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Current latent presentation must not broaden frozen source/JSON admission.
|
||||
use oa_core::{view_options::*, *};
|
||||
|
||||
fn legacy_json(project: &Project, version: u32) -> Vec<u8> {
|
||||
let mut value = serde_json::to_value(project).unwrap();
|
||||
value["schema_version"] = version.into();
|
||||
for diagram in value["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
let fields = diagram.as_object_mut().unwrap();
|
||||
fields.remove("layout");
|
||||
if version < 6 {
|
||||
fields.remove("view_options");
|
||||
}
|
||||
}
|
||||
serde_json::to_vec(&value).unwrap()
|
||||
}
|
||||
|
||||
fn model_issue(error: persistence::FileError) -> validation::Issue {
|
||||
match error {
|
||||
persistence::FileError::Model { mut issues, .. } => issues.remove(0),
|
||||
other => panic!("Expected a located model issue, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() {
|
||||
let node_id = Id::parse_str("00000000-0000-4000-8000-000000000004").unwrap();
|
||||
for version in 3..=6 {
|
||||
let source = format!(
|
||||
"arcadia {version}; project compat \"Compatibility\" id \"00000000-0000-4000-8000-000000000001\" {{\n\
|
||||
sa_function work \"Counterpart\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n\
|
||||
diagram view \"OA concern\" id \"00000000-0000-4000-8000-000000000003\" {{ layer Oa; projection Traceability; }}\n\
|
||||
node occurrence id \"00000000-0000-4000-8000-000000000004\" {{ diagram @view; element @work; }}\n}}"
|
||||
);
|
||||
let mixed = language::parse(&source).unwrap();
|
||||
assert_eq!(
|
||||
persistence::decode(&legacy_json(&mixed, version)).unwrap(),
|
||||
mixed
|
||||
);
|
||||
|
||||
let ordinary = source.replace("projection Traceability", "projection Overview");
|
||||
let same_layer = ordinary.replace("layer Oa", "layer Sa");
|
||||
let ordinary_project = language::parse(&same_layer).unwrap();
|
||||
assert_eq!(
|
||||
persistence::decode(&legacy_json(&ordinary_project, version)).unwrap(),
|
||||
ordinary_project
|
||||
);
|
||||
let errors = language::parse(&ordinary).unwrap_err();
|
||||
// Source3 already reported migration errors with the offending location.
|
||||
let code = if version == 3 {
|
||||
"migration_error"
|
||||
} else {
|
||||
"diagram_layer"
|
||||
};
|
||||
assert!(
|
||||
errors.iter().any(|error| error.code == code
|
||||
&& error.line == 4
|
||||
&& error.message.contains("selected layer")),
|
||||
"{version}: {errors:?}"
|
||||
);
|
||||
|
||||
let mut latent = mixed.clone();
|
||||
let diagram = latent.diagrams.values_mut().next().unwrap();
|
||||
diagram.projection = ProjectionKind::Overview;
|
||||
let node = diagram.nodes.values().next().unwrap().id;
|
||||
assert_eq!(node, node_id);
|
||||
if version >= 4 {
|
||||
let diagnostic = errors
|
||||
.iter()
|
||||
.find(|error| error.code == "diagram_layer")
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.object_id, Some(node_id));
|
||||
assert_eq!(diagnostic.field.as_deref(), Some("element"));
|
||||
let bundle = source_project::SourceBundle {
|
||||
entry: "model.arcadia".into(),
|
||||
files: std::collections::BTreeMap::from([("model.arcadia".into(), ordinary)]),
|
||||
};
|
||||
let errors =
|
||||
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default())
|
||||
.unwrap_err();
|
||||
let diagnostic = &errors
|
||||
.iter()
|
||||
.find(|error| error.diagnostic.code == "diagram_layer")
|
||||
.unwrap()
|
||||
.diagnostic;
|
||||
assert_eq!(diagnostic.object_id, Some(node_id));
|
||||
assert_eq!(diagnostic.field.as_deref(), Some("element"));
|
||||
}
|
||||
let issue = model_issue(persistence::decode(&legacy_json(&latent, version)).unwrap_err());
|
||||
assert_eq!(issue.code, "diagram_layer");
|
||||
assert_eq!(issue.element, Some(node));
|
||||
assert_eq!(issue.field.as_deref(), Some("element"));
|
||||
// The exact same record is valid under the current presentation contract.
|
||||
assert_eq!(language::parse(&language::format(&latent)).unwrap(), latent);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&latent).unwrap()).unwrap(),
|
||||
latent
|
||||
);
|
||||
let mut dangling = mixed;
|
||||
dangling
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.element = Id::parse_str("00000000-0000-4000-8000-000000000005").unwrap();
|
||||
let issue = model_issue(persistence::decode(&legacy_json(&dangling, version)).unwrap_err());
|
||||
assert_eq!(issue.code, "diagram_layer");
|
||||
assert_eq!(issue.element, Some(node));
|
||||
assert_eq!(issue.field.as_deref(), Some("element"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_6_checks_every_layer_sensitive_saved_selector_before_upgrade() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "A", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "B", None)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Interaction", a, b);
|
||||
let category = project
|
||||
.add_element(Kind::ExchangeCategory, Layer::Oa, "Work category", None)
|
||||
.unwrap();
|
||||
let participant_category = project
|
||||
.add_element(
|
||||
Kind::ExchangeCategory,
|
||||
Layer::Oa,
|
||||
"Participant category",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&participant_category)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Participant,
|
||||
exchanges: vec![],
|
||||
};
|
||||
let context = project
|
||||
.add_element(Kind::Process, Layer::Oa, "Process", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
for (field, options) in [
|
||||
(
|
||||
"hidden_edges",
|
||||
ViewOptions {
|
||||
hidden_edges: vec![edge],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"work_categories",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![category]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"participant_categories",
|
||||
ViewOptions {
|
||||
participant_categories: CategorySelection::Any(vec![participant_category]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"work_contexts",
|
||||
ViewOptions {
|
||||
work_contexts: ContextSelection::Any(vec![context]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
project.diagrams.get_mut(&diagram).unwrap().view_options = options;
|
||||
let issue = model_issue(persistence::decode(&legacy_json(&project, 6)).unwrap_err());
|
||||
assert_eq!(issue.code, "view_options");
|
||||
assert_eq!(issue.element, Some(diagram));
|
||||
assert_eq!(issue.field.as_deref(), Some(field));
|
||||
let current_source = language::format(&project);
|
||||
assert_eq!(language::parse(¤t_source).unwrap(), project);
|
||||
let source6 = current_source.replacen("arcadia 7;", "arcadia 6;", 1);
|
||||
let errors = language::parse(&source6).unwrap_err();
|
||||
let diagnostic = errors
|
||||
.iter()
|
||||
.find(|error| error.code == "view_options")
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.object_id, Some(diagram));
|
||||
assert_eq!(diagnostic.field.as_deref(), Some(field));
|
||||
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
assert_eq!(
|
||||
persistence::decode(&legacy_json(&project, 6)).unwrap(),
|
||||
project
|
||||
);
|
||||
let source6 = language::format(&project).replacen("arcadia 7;", "arcadia 6;", 1);
|
||||
assert_eq!(language::parse(&source6).unwrap(), project);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Populated presentation survives mixed-layer projection switches.
|
||||
//! Complements projection_switching.rs without external fixture dependencies.
|
||||
use oa_core::{
|
||||
projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *,
|
||||
};
|
||||
|
||||
struct Populated {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
groups: [Id; 2],
|
||||
children: [[Id; 2]; 2],
|
||||
edges: [Id; 2],
|
||||
ports: [Id; 2],
|
||||
trace: Id,
|
||||
}
|
||||
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn apply(project: &mut Project, command: Command) {
|
||||
*project = prepare_command(project, &command).unwrap().project;
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer, collapsed: bool) -> Populated {
|
||||
let mut p = Project::empty();
|
||||
let oa_group = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational response", None)
|
||||
.unwrap();
|
||||
let oa_send = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group))
|
||||
.unwrap();
|
||||
let oa_receive = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group))
|
||||
.unwrap();
|
||||
let sa_group = p
|
||||
.add_element(Kind::Function, Layer::Sa, "System response", None)
|
||||
.unwrap();
|
||||
let sa_send = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group))
|
||||
.unwrap();
|
||||
let sa_receive = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group))
|
||||
.unwrap();
|
||||
let oa_edge = p.relate(
|
||||
RelationKind::Interaction,
|
||||
"Repair request",
|
||||
oa_send,
|
||||
oa_receive,
|
||||
);
|
||||
let sa_edge = p.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Repair dispatch",
|
||||
sa_send,
|
||||
sa_receive,
|
||||
);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&sa_edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let trace = Id::new_v4();
|
||||
let refinement = Trace {
|
||||
id: trace,
|
||||
alias: p.fresh_alias("dispatch_refinement", "trace", trace),
|
||||
name: "Dispatch refines repair request".into(),
|
||||
description: "Keep this cross-layer trace and its authored route.".into(),
|
||||
oa: oa_send,
|
||||
sa: sa_send,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa_send).unwrap(),
|
||||
sa: p.properties(sa_send).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
};
|
||||
apply(&mut p, Command::CreateTrace(refinement));
|
||||
let d = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
let oa_group_node = p.place(d, oa_group, xy(0., 0.));
|
||||
let oa_send_node = p.place(d, oa_send, xy(40., 120.));
|
||||
let oa_receive_node = p.place(d, oa_receive, xy(360., 120.));
|
||||
let sa_group_node = p.place(d, sa_group, xy(0., 500.));
|
||||
let sa_send_node = p.place(d, sa_send, xy(40., 620.));
|
||||
let sa_receive_node = p.place(d, sa_receive, xy(360., 620.));
|
||||
for (node, port, side, offset) in [
|
||||
(sa_send_node, source_port, PortAnchorSide::Bottom, 0.25),
|
||||
(sa_receive_node, target_port, PortAnchorSide::Top, 0.75),
|
||||
] {
|
||||
apply(
|
||||
&mut p,
|
||||
Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
);
|
||||
}
|
||||
for (edge, y, port_backed) in [
|
||||
(oa_edge, 260., false),
|
||||
(sa_edge, 860., true),
|
||||
(trace, 420., false),
|
||||
] {
|
||||
apply(
|
||||
&mut p,
|
||||
Command::SetEdgeRoute {
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: (!port_backed).then_some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.3,
|
||||
}),
|
||||
target_attachment: (!port_backed).then_some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
waypoints: vec![xy(680., y), xy(740., y)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: xy(13., -27.),
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
for node in [oa_group_node, sa_group_node] {
|
||||
let mut placement = p.diagrams[&d].nodes[&node].clone();
|
||||
placement.collapsed = collapsed;
|
||||
placement.size = Size {
|
||||
width: 310.,
|
||||
height: 170.,
|
||||
};
|
||||
apply(
|
||||
&mut p,
|
||||
Command::EditNode {
|
||||
diagram: d,
|
||||
original: node,
|
||||
node: placement,
|
||||
},
|
||||
);
|
||||
}
|
||||
validate_candidate(&p).unwrap();
|
||||
Populated {
|
||||
project: p,
|
||||
diagram: d,
|
||||
groups: [oa_group_node, sa_group_node],
|
||||
children: [
|
||||
[oa_send_node, oa_receive_node],
|
||||
[sa_send_node, sa_receive_node],
|
||||
],
|
||||
edges: [oa_edge, sa_edge],
|
||||
ports: [source_port, target_port],
|
||||
trace,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_round_trips(p: &Project) {
|
||||
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(),
|
||||
*p
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(p), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*p
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) {
|
||||
let layer = p.diagrams[&d].layer;
|
||||
for node in &view.nodes {
|
||||
assert_eq!(p.elements[&node.element].layer(), layer);
|
||||
if let Some(parent) = node.visual_parent {
|
||||
let parent = view.nodes.iter().find(|n| n.id == parent).unwrap();
|
||||
assert_eq!(p.elements[&parent.element].layer(), layer);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
assert_eq!(p.elements[&port.element].layer(), layer);
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
assert_eq!(p.elements[&owner.element].layer(), layer);
|
||||
}
|
||||
for edge in &view.edges {
|
||||
assert!(
|
||||
!edge.trace,
|
||||
"cross-layer trace leaked into ordinary projection"
|
||||
);
|
||||
assert_eq!(p.layer_of(edge.id), Some(layer));
|
||||
for endpoint in [edge.source, edge.target] {
|
||||
let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap();
|
||||
assert_eq!(p.elements[&owner.element].layer(), layer);
|
||||
}
|
||||
for port in [edge.source_port, edge.target_port].into_iter().flatten() {
|
||||
assert_eq!(p.elements[&port].layer(), layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for collapsed in [false, true] {
|
||||
let f = fixture(layer, collapsed);
|
||||
let baseline = &f.project;
|
||||
let d = f.diagram;
|
||||
let before_view = project_view(baseline, d);
|
||||
let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view);
|
||||
// Guard against a vacuous fixture: Traceability deliberately flattens parents.
|
||||
assert_eq!(before_view.nodes.len(), 6);
|
||||
assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none()));
|
||||
for id in [f.edges[0], f.edges[1], f.trace] {
|
||||
assert!(before_view.edges.iter().any(|e| e.id == id));
|
||||
let route = baseline.diagrams[&d]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == id)
|
||||
.unwrap();
|
||||
assert_eq!(route.waypoints.len(), 2);
|
||||
assert!(route.label.is_some());
|
||||
}
|
||||
for port in f.ports {
|
||||
assert!(before_view.ports.iter().any(|p| p.element == port));
|
||||
}
|
||||
assert_round_trips(baseline);
|
||||
for projection in [
|
||||
ProjectionKind::Overview,
|
||||
ProjectionKind::Decomposition,
|
||||
ProjectionKind::Allocation,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
let mut expected = baseline.clone();
|
||||
expected.diagrams.get_mut(&d).unwrap().projection = projection;
|
||||
let mut session = Session::new(baseline.clone());
|
||||
session
|
||||
.execute(Command::EditDiagram(d, expected.diagrams[&d].clone()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_round_trips(session.project());
|
||||
let view = project_view(session.project(), d);
|
||||
assert_eq!(
|
||||
view,
|
||||
project_view_with_index(&expected, d, &ViewIndex::new(&expected))
|
||||
);
|
||||
assert_layer_only(session.project(), d, &view);
|
||||
if projection == ProjectionKind::Overview {
|
||||
let own = if layer == Layer::Oa { 0 } else { 1 };
|
||||
assert!(view.nodes.iter().any(|n| n.id == f.groups[own]));
|
||||
for child in f.children[own] {
|
||||
if collapsed {
|
||||
assert!(!view.nodes.iter().any(|n| n.id == child));
|
||||
} else {
|
||||
assert_eq!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == child)
|
||||
.unwrap()
|
||||
.visual_parent,
|
||||
Some(f.groups[own])
|
||||
);
|
||||
}
|
||||
}
|
||||
if !collapsed {
|
||||
assert!(view.edges.iter().any(|e| e.id == f.edges[own]));
|
||||
if layer == Layer::Sa {
|
||||
assert_eq!(view.ports.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
session.undo();
|
||||
assert_eq!(session.project(), baseline);
|
||||
assert!(
|
||||
!session.can_undo(),
|
||||
"projection change must be one model history step"
|
||||
);
|
||||
assert_round_trips(session.project());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::EditDiagram(d, baseline.diagrams[&d].clone()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), baseline);
|
||||
let restored = project_view(session.project(), d);
|
||||
assert_eq!(restored, before_view);
|
||||
assert_eq!(
|
||||
route_diagram(
|
||||
session.project(),
|
||||
&session.project().diagrams[&d],
|
||||
&restored
|
||||
),
|
||||
before_routes
|
||||
);
|
||||
assert_round_trips(session.project());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use oa_core::{source_project::*, view_options::*, *};
|
||||
|
||||
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess alarm", None)
|
||||
.unwrap();
|
||||
let oa2 = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess alarm", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Alarm", oa, oa2);
|
||||
let category = p
|
||||
.add_element(Kind::ExchangeCategory, Layer::Oa, "Alarm data", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: vec![edge],
|
||||
};
|
||||
let context = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Alarm handling", None)
|
||||
.unwrap();
|
||||
let d = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
for (i, element) in [oa, oa2, sa].into_iter().enumerate() {
|
||||
p.place(
|
||||
d,
|
||||
element,
|
||||
Position {
|
||||
x: 300. * i as f32,
|
||||
y: 40.,
|
||||
},
|
||||
);
|
||||
}
|
||||
p.diagrams.get_mut(&d).unwrap().view_options = ViewOptions {
|
||||
hidden_edges: vec![edge],
|
||||
work_categories: CategorySelection::Any(vec![category]),
|
||||
work_contexts: ContextSelection::Any(vec![context]),
|
||||
..Default::default()
|
||||
};
|
||||
validate_candidate(&p).unwrap();
|
||||
(p, d, oa, sa, category)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_switching_preserves_complete_model_history_and_all_serializations() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (baseline, diagram, _, _, _) = fixture(layer);
|
||||
for projection in [
|
||||
ProjectionKind::Overview,
|
||||
ProjectionKind::Decomposition,
|
||||
ProjectionKind::Allocation,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
let mut expected = baseline.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().projection = projection;
|
||||
let mut session = Session::new(baseline.clone());
|
||||
session
|
||||
.execute(Command::EditDiagram(
|
||||
diagram,
|
||||
expected.diagrams[&diagram].clone(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
let view = projection::project_view(session.project(), diagram);
|
||||
assert!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.all(|n| expected.elements[&n.element].layer() == layer)
|
||||
);
|
||||
assert_eq!(
|
||||
view,
|
||||
projection::project_view_with_index(&expected, diagram, &ViewIndex::new(&expected))
|
||||
);
|
||||
let source = language::format(&expected);
|
||||
assert_eq!(language::parse(&source).unwrap(), expected);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&expected).unwrap()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let bundle = format_bundle(&expected);
|
||||
assert_eq!(
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &baseline);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::EditDiagram(
|
||||
diagram,
|
||||
baseline.diagrams[&diagram].clone(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &baseline);
|
||||
assert_eq!(
|
||||
projection::project_view(session.project(), diagram),
|
||||
projection::project_view(&baseline, diagram)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_switching_does_not_admit_missing_elements_or_invalid_filter_kinds() {
|
||||
let (mut p, d, oa, _, category) = fixture(Layer::Sa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
validate_candidate(&p).unwrap();
|
||||
let valid = p.clone();
|
||||
let node = p.diagrams[&d]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == oa)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.element = Id::new_v4();
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
p = valid.clone();
|
||||
p.diagrams.get_mut(&d).unwrap().view_options.work_categories = CategorySelection::Any(vec![oa]);
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
p = valid;
|
||||
p.elements.remove(&category);
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_switching_keeps_typed_state_membership_strict() {
|
||||
let (mut p, d, _, _, _) = fixture(Layer::Sa);
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
|
||||
.unwrap();
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "state_diagram_member")
|
||||
);
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
}
|
||||
@@ -67,7 +67,18 @@ fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches
|
||||
.any(|e| e.id == interaction)
|
||||
);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
validate_candidate(&p).unwrap();
|
||||
let ordinary = projection::project_view_with_index(&p, d, &index);
|
||||
assert!(
|
||||
ordinary
|
||||
.nodes
|
||||
.iter()
|
||||
.all(|n| p.elements[&n.element].layer() == Layer::Oa)
|
||||
);
|
||||
assert!(ordinary.edges.iter().any(|e| e.id == interaction));
|
||||
assert!(!ordinary.edges.iter().any(|e| e.id == fe));
|
||||
assert_eq!(p.diagrams[&d].nodes.len(), 4);
|
||||
assert_eq!(p.diagrams[&d].view_options.hidden_edges, vec![fe]);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(
|
||||
|
||||
@@ -60,7 +60,6 @@ impl Root<'_> {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &self.app.saved_removal_input,
|
||||
selection: self.app.diagram_selection.as_ref(),
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
|
||||
+217
-232
@@ -4,8 +4,12 @@ pub(crate) mod architecture_authoring;
|
||||
mod architecture_authoring_tests;
|
||||
#[cfg(test)]
|
||||
mod behavior_removal_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_selection_tests;
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod connection_begin_regressions;
|
||||
#[cfg(test)]
|
||||
mod diagram_focus_tests;
|
||||
pub(crate) mod diagram_removal;
|
||||
#[cfg(test)]
|
||||
@@ -19,18 +23,14 @@ mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
pub(crate) mod process_removal;
|
||||
#[cfg(test)]
|
||||
mod placement_relocation_regressions;
|
||||
mod reference_authoring;
|
||||
mod removal_review;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
pub(crate) mod saved_removal;
|
||||
#[cfg(test)]
|
||||
mod scenario_details_tests;
|
||||
mod scenario_diagram;
|
||||
mod scenario_multiselect;
|
||||
pub(crate) mod scenario_removal;
|
||||
pub(crate) mod scenario_reorder;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
@@ -221,14 +221,11 @@ mod direct_scenario_tests {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, f.sender,
|
||||
)));
|
||||
let fresh = app.scenario_context().unwrap();
|
||||
assert_eq!(fresh.visit, context.visit);
|
||||
assert_eq!(fresh.representation, context.representation);
|
||||
assert_ne!(
|
||||
fresh.epoch, context.epoch,
|
||||
"selection retires older gestures"
|
||||
assert_eq!(
|
||||
app.scenario_context(),
|
||||
Some(context),
|
||||
"same-owner selection retains gesture context"
|
||||
);
|
||||
let context = fresh;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: created,
|
||||
@@ -390,6 +387,7 @@ pub enum Message {
|
||||
Behavior(crate::behavior::Event),
|
||||
Layer(Layer),
|
||||
Select(Id),
|
||||
CanvasSelect(crate::route_edit::Context, Id),
|
||||
EditProperties(Id),
|
||||
SelectNode(Id),
|
||||
DiagramSelection(multiselect::Event),
|
||||
@@ -446,10 +444,6 @@ pub enum Message {
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
ScenarioReorder(Box<scenario_reorder::Event>),
|
||||
ScenarioRemoval(Box<scenario_removal::Event>),
|
||||
ProcessRemoval(Box<process_removal::Event>),
|
||||
SavedRemoval(Box<saved_removal::Event>),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
@@ -536,14 +530,12 @@ pub struct App {
|
||||
pub guide: crate::guide::State,
|
||||
pub traceability: crate::views::traceability::State,
|
||||
pub behavior: crate::behavior::State,
|
||||
pub(crate) scenario_reorder: scenario_reorder::State,
|
||||
pub data_state: crate::data_view::State,
|
||||
pub inspector_tab: crate::navigation::InspectorTab,
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
pub selected_node: Option<Id>,
|
||||
pub diagram_selection: Option<crate::diagram_selection::Selection>,
|
||||
pub(crate) saved_removal_input: crate::removal_input::Lifetime,
|
||||
pub draft: Option<Draft>,
|
||||
pub active_diagram: Option<Id>,
|
||||
pub search: String,
|
||||
@@ -660,14 +652,12 @@ impl App {
|
||||
guide: crate::guide::State::default(),
|
||||
traceability: Default::default(),
|
||||
behavior: Default::default(),
|
||||
scenario_reorder: Default::default(),
|
||||
data_state: Default::default(),
|
||||
inspector_tab: Default::default(),
|
||||
layer,
|
||||
selected,
|
||||
selected_node: None,
|
||||
diagram_selection: None,
|
||||
saved_removal_input: Default::default(),
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
@@ -964,6 +954,16 @@ impl App {
|
||||
return true;
|
||||
}
|
||||
let original = self.selected;
|
||||
let canvas_surface = self.architecture_surface();
|
||||
let placement_owner = self.draft.as_ref().and_then(|draft| match draft.kind {
|
||||
crate::forms::RecordKind::Node(owner) => self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&owner)
|
||||
.filter(|diagram| diagram.nodes.contains_key(&draft.original))
|
||||
.map(|diagram| diagram.id),
|
||||
_ => None,
|
||||
});
|
||||
let result = self
|
||||
.draft
|
||||
.as_ref()
|
||||
@@ -1018,13 +1018,16 @@ impl App {
|
||||
if let Some(layer) = self.project().layer_of(id) {
|
||||
self.layer = layer;
|
||||
}
|
||||
if let Some((diagram, layer, node)) = self
|
||||
let selected_surface = self
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.id == id || d.nodes.contains_key(&id))
|
||||
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
|
||||
{
|
||||
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)));
|
||||
let relocated_placement = placement_owner
|
||||
.zip(selected_surface.and_then(|(diagram, _, node)| node.map(|_| diagram)))
|
||||
.is_some_and(|(before, after)| before != after);
|
||||
if let Some((diagram, layer, node)) = selected_surface {
|
||||
self.layer = layer;
|
||||
if self.page == Page::Behavior
|
||||
&& let Some(machine) =
|
||||
@@ -1038,6 +1041,20 @@ impl App {
|
||||
self.selected_node = node;
|
||||
}
|
||||
}
|
||||
// Ordinary Properties edits retain their saved architecture view.
|
||||
// Follow an explicitly edited diagram identity/layer, never an
|
||||
// inferred replacement for an unrelated queued canvas click.
|
||||
// An accepted placement move explicitly chooses its new view.
|
||||
// Same-owner edits (including a new UUID) still retain inspection.
|
||||
if !relocated_placement && let Some((diagram, _, _)) = canvas_surface {
|
||||
let diagram = if diagram == original { id } else { diagram };
|
||||
if let Some(view) = self.project().diagrams.get(&diagram)
|
||||
&& view.projection.is_architecture()
|
||||
{
|
||||
self.layer = view.layer;
|
||||
self.active_diagram = Some(diagram);
|
||||
}
|
||||
}
|
||||
self.sync();
|
||||
self.status = "Properties applied".into();
|
||||
true
|
||||
@@ -1188,7 +1205,13 @@ impl App {
|
||||
if id != self.selected {
|
||||
self.selected_attachment = None;
|
||||
}
|
||||
if self.selected == id && !process_preview {
|
||||
if self.selected == id
|
||||
&& !process_preview
|
||||
&& self
|
||||
.project()
|
||||
.layer_of(id)
|
||||
.is_none_or(|layer| layer == self.layer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.apply() {
|
||||
@@ -1234,6 +1257,59 @@ impl App {
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
/// The saved architecture surface, independent of the Inspector's object.
|
||||
fn architecture_surface(&self) -> Option<(Id, Layer, ProjectionKind)> {
|
||||
if self.page != Page::Diagram {
|
||||
return None;
|
||||
}
|
||||
let diagram = self.project().diagrams.get(&self.active_diagram?)?;
|
||||
diagram.projection.is_architecture().then_some((
|
||||
diagram.id,
|
||||
diagram.layer,
|
||||
diagram.projection,
|
||||
))
|
||||
}
|
||||
fn canvas_target_visible(&self, diagram: Id, id: Id) -> bool {
|
||||
let view = project_view(self.project(), diagram);
|
||||
view.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == id || node.element == id)
|
||||
|| view.ports.iter().any(|port| port.element == id)
|
||||
|| view.edges.iter().any(|edge| edge.id == id)
|
||||
}
|
||||
/// Inspect a contextual target without following its model layer. An edit
|
||||
/// may invalidate a previously visible occurrence; accept the edit but
|
||||
/// refuse that old hit. Invisible Show-in-view targets remain intentional.
|
||||
fn inspect_canvas_target(&mut self, id: Id) -> bool {
|
||||
if self.selected == id {
|
||||
return true;
|
||||
}
|
||||
let surface = self.architecture_surface();
|
||||
let accepted_draft = self.draft_dirty();
|
||||
let was_visible = accepted_draft
|
||||
&& surface.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id));
|
||||
if !self.project().contains(id) || !self.apply() {
|
||||
return false;
|
||||
}
|
||||
if !self.project().contains(id)
|
||||
|| (was_visible
|
||||
&& (self.architecture_surface() != surface
|
||||
|| !surface
|
||||
.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id))))
|
||||
{
|
||||
if accepted_draft {
|
||||
self.status =
|
||||
"Properties applied. The view changed; select a visible target again.".into();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.selected = id;
|
||||
self.selected_node = None;
|
||||
self.selected_attachment = None;
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
}
|
||||
fn reveal_explorer_selection(&mut self) {
|
||||
if let Some(group) = self.explorer_index.group(self.selected) {
|
||||
self.explorer_groups_collapsed.remove(&group);
|
||||
@@ -1365,128 +1441,8 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// Group dialogs only accept their stamped Cancel button. A delayed
|
||||
// singleton dialog packet cannot close a later aggregate review.
|
||||
if matches!(message, Message::CancelDelete)
|
||||
&& self.delete_target.as_ref().is_some_and(|target| {
|
||||
target.scenario.is_some() || target.process.is_some() || target.saved.is_some()
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::SavedRemoval(event) = message {
|
||||
return self.saved_removal_event(*event);
|
||||
}
|
||||
if let Message::StateDiagram(behavior::state_edit::Event::ModelRemoval(event)) = message {
|
||||
return self.saved_removal_event(*event);
|
||||
}
|
||||
if let Message::ProcessRemoval(event) = message {
|
||||
return self.process_removal_event(*event);
|
||||
}
|
||||
// Process review Escape is captured with its token by the root widget.
|
||||
if matches!(message, Message::Escape)
|
||||
&& self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.process.is_some() || target.saved.is_some())
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::ScenarioRemoval(event) = message {
|
||||
return self.scenario_removal_event(*event);
|
||||
}
|
||||
if let Message::ConfirmDelete(token) = &message {
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_none_or(|target| target.snapshot.token != *token)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.saved.is_some())
|
||||
{
|
||||
self.confirm_saved_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.scenario.is_some())
|
||||
{
|
||||
self.confirm_scenario_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.process.is_some())
|
||||
{
|
||||
self.confirm_process_removal(*token);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
// Captured reorder packets must be admitted before shared navigation,
|
||||
// camera or source boundaries can disturb a newer operation.
|
||||
if let Message::ScenarioReorder(event) = message {
|
||||
return self.scenario_reorder_event(*event);
|
||||
}
|
||||
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Selection(event)) = message
|
||||
{
|
||||
return self.scenario_selection_event(*event);
|
||||
}
|
||||
// A delayed Scenario menu cannot retire the current reveal/gesture even
|
||||
// when it uses the same semantic owner after a representation round trip.
|
||||
if let Message::Menu(crate::model_menu::Event::Open(request)) = &message
|
||||
&& (request.scenario.is_some() || request.process.is_some() || request.canvas.is_some())
|
||||
&& crate::model_menu::Opened::new(self, request.clone(), self.menu_sequence).is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::Menu(crate::model_menu::Event::Invoke(token, _)) = &message
|
||||
&& let Some(menu) = &self.model_menu
|
||||
&& (menu.request.scenario.is_some()
|
||||
|| menu.request.process.is_some()
|
||||
|| menu.request.canvas.is_some())
|
||||
&& (menu.token != *token || !menu.valid(self))
|
||||
{
|
||||
// Consume an expired menu's own packet without touching a newer
|
||||
// menu or crossing source/selection boundaries.
|
||||
if menu.token == *token {
|
||||
self.model_menu = None;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
&message,
|
||||
Message::Command(Command::MoveStep(..)) | Message::Delete
|
||||
)
|
||||
{
|
||||
self.status =
|
||||
"Use Reorder or Remove above the canvas to act on the complete Scenario selection."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.topology_context().is_some()
|
||||
&& self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
&& matches!(&message, Message::Delete)
|
||||
{
|
||||
self.status =
|
||||
"Use Remove selected above the canvas to review the complete Process selection."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
let preview_read_only = self.process_preview_primary().is_some();
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
let scenario_selection_before = self.scenario_context().and_then(|context| {
|
||||
self.multiselect_snapshot()
|
||||
.map(|selection| (context, selection))
|
||||
});
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(captured, _)
|
||||
@@ -1500,6 +1456,11 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::CanvasSelect(captured, _) = &message
|
||||
&& (self.page != Page::Diagram || self.route_context() != Some(*captured))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Selection can change without advancing the captured canvas context.
|
||||
if let Message::Route(
|
||||
crate::route_edit::Event::Commit(captured, changed, ..)
|
||||
@@ -1655,16 +1616,6 @@ impl App {
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
event,
|
||||
behavior::scenario::CanvasEvent::Select(..)
|
||||
| behavior::scenario::CanvasEvent::Remove(..)
|
||||
| behavior::scenario::CanvasEvent::Reorder { .. }
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Passive layout/form reports retain their existing epoch rules;
|
||||
// camera input uses its separate lifetime below. All other actions
|
||||
// must match the exact context before shared reveal reconciliation.
|
||||
@@ -1752,20 +1703,9 @@ impl App {
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
if let Some((context, selection)) = scenario_selection_before
|
||||
&& self.scenario_context() == Some(context)
|
||||
&& self.multiselect_snapshot().as_ref() != Some(&selection)
|
||||
{
|
||||
// Legacy singleton selection retires old spacing packets. If the
|
||||
// action already advanced its context, its new navigation/reveal
|
||||
// owns that context and must not be retired by reconciliation.
|
||||
self.behavior.scenario.cancel_spacing();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
if preview_read_only && self.process_preview_primary().is_none() && self.draft.is_none() {
|
||||
self.refresh_draft();
|
||||
}
|
||||
self.scenario_reorder_reconcile();
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -2276,6 +2216,7 @@ impl App {
|
||||
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
|
||||
Message::Projection(projection) => active || projection.scenario().is_some(),
|
||||
Message::SelectNode(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::PlaceSelected
|
||||
| Message::HideSelected
|
||||
| Message::ToggleCollapse
|
||||
@@ -2316,10 +2257,6 @@ impl App {
|
||||
}
|
||||
Message::CheckpointLoaded(result) => return self.checkpoint_loaded(result),
|
||||
Message::Hosted(event) => return self.hosted_event(event),
|
||||
Message::ScenarioReorder(event) => return self.scenario_reorder_event(*event),
|
||||
Message::ScenarioRemoval(event) => return self.scenario_removal_event(*event),
|
||||
Message::ProcessRemoval(event) => return self.process_removal_event(*event),
|
||||
Message::SavedRemoval(event) => return self.saved_removal_event(*event),
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
@@ -2828,9 +2765,6 @@ impl App {
|
||||
if request.process.is_some() {
|
||||
request.process = self.topology_context().map(Box::new);
|
||||
}
|
||||
if request.scenario.is_some() {
|
||||
request.scenario = self.scenario_context().map(Box::new);
|
||||
}
|
||||
self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
|
||||
}
|
||||
Message::Menu(crate::model_menu::Event::Close) => {
|
||||
@@ -2860,21 +2794,9 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
*action,
|
||||
Message::DiagramSelection(_) | Message::SavedRemoval(_)
|
||||
) || (menu.request.process.is_some()
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::TopologyCanvas(_) | Message::ProcessRemoval(_)
|
||||
))
|
||||
|| (menu.request.scenario.is_some()
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::ScenarioCanvas(_)
|
||||
| Message::ScenarioReorder(_)
|
||||
| Message::ScenarioRemoval(_)
|
||||
))
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2895,10 +2817,26 @@ impl App {
|
||||
e.kind() != Kind::Port && d.projection.includes(e.kind())
|
||||
})
|
||||
});
|
||||
if show_in_mixed_view {
|
||||
self.selected = menu.request.object;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
// Draw acts on the visible current view even when opened from
|
||||
// Explorer; explicit Select/Open actions still navigate.
|
||||
let draw_in_current_view = match &*action {
|
||||
Message::Connection(crate::connection_edit::Event::Begin(tool, source)) => {
|
||||
*source == menu.request.object
|
||||
&& self.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::can_begin(
|
||||
self.project(),
|
||||
diagram,
|
||||
*tool,
|
||||
*source,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if show_in_mixed_view || draw_in_current_view || menu.request.canvas.is_some() {
|
||||
if !self.inspect_canvas_target(menu.request.object) {
|
||||
return Task::none();
|
||||
}
|
||||
} else {
|
||||
self.select(menu.request.object);
|
||||
}
|
||||
@@ -2910,17 +2848,21 @@ impl App {
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
let action = match *action {
|
||||
Message::EditProperties(id)
|
||||
if menu.request.canvas.is_some() && id == menu.request.object =>
|
||||
{
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Properties)
|
||||
}
|
||||
action => action,
|
||||
};
|
||||
return self.update(action);
|
||||
}
|
||||
Message::CreateContextElement(kind, child) => {
|
||||
self.create_kind = kind;
|
||||
return self.update(Message::Add(child));
|
||||
}
|
||||
Message::Escape => {
|
||||
if self.scenario_reorder.session.is_some() {
|
||||
self.scenario_reorder_cancel();
|
||||
return Task::none();
|
||||
}
|
||||
let restore_source_focus = self.source_navigation_available()
|
||||
&& (self.source_navigation.open || self.text_workspace.drawer().is_some());
|
||||
let dismiss_find_only = restore_source_focus && self.source_navigation.open;
|
||||
@@ -3070,6 +3012,14 @@ impl App {
|
||||
self.status = "Property draft reverted".into();
|
||||
}
|
||||
Message::Select(id) => self.select(id),
|
||||
Message::CanvasSelect(context, id) => {
|
||||
let view = project_view(self.project(), context.diagram);
|
||||
if view.ports.iter().any(|port| port.element == id)
|
||||
|| view.edges.iter().any(|edge| edge.id == id)
|
||||
{
|
||||
self.inspect_canvas_target(id);
|
||||
}
|
||||
}
|
||||
Message::DiagramSelection(event) => self.multiselect_event(event),
|
||||
Message::EditProperties(id) => {
|
||||
self.select(id);
|
||||
@@ -3089,10 +3039,23 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
let surface = self.architecture_surface();
|
||||
let accepted_draft = self.draft_dirty();
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let id = if id == original { self.selected } else { id };
|
||||
if let Some((diagram, _, _)) = surface
|
||||
&& (self.architecture_surface() != surface
|
||||
|| !self.canvas_target_visible(diagram, id))
|
||||
{
|
||||
if accepted_draft {
|
||||
self.status =
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
.into();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(d) = self
|
||||
.active_diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
@@ -3517,7 +3480,7 @@ impl App {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
|
||||
self.project(),
|
||||
self.selected,
|
||||
self.layer,
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer),
|
||||
kind,
|
||||
incoming,
|
||||
));
|
||||
@@ -3789,9 +3752,9 @@ impl App {
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = if self.topology_context().is_some() {
|
||||
"Use Remove selected above the canvas to review the complete Process selection."
|
||||
"Group removal needs an aggregate review. Select one contribution to remove it individually."
|
||||
} else {
|
||||
"Use Delete from model above the canvas to review the complete selection, or Hide selected to remove placements."
|
||||
"Group model-deletion review is not available yet. Select one element or use Hide selected."
|
||||
}.into();
|
||||
return Task::none();
|
||||
}
|
||||
@@ -4543,13 +4506,18 @@ impl App {
|
||||
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
|
||||
return;
|
||||
}
|
||||
let creation_layer = if child {
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer)
|
||||
} else {
|
||||
self.layer
|
||||
};
|
||||
let allocated = child
|
||||
&& self.create_kind.is_work()
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&self.selected)
|
||||
.is_some_and(|e| e.layer() == self.layer && e.kind().is_performer());
|
||||
.is_some_and(|e| e.layer() == creation_layer && e.kind().is_performer());
|
||||
let parent = if child && !allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
@@ -4557,7 +4525,7 @@ impl App {
|
||||
};
|
||||
let Some(e) = self.project().make_element(
|
||||
self.create_kind,
|
||||
self.layer,
|
||||
creation_layer,
|
||||
&format!("New {}", self.create_kind.label().to_lowercase()),
|
||||
parent,
|
||||
) else {
|
||||
@@ -4586,16 +4554,16 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == self.layer && d.projection == preferred)
|
||||
.find(|d| d.layer == creation_layer && d.projection == preferred)
|
||||
.map(|d| d.id);
|
||||
if diagram_target.is_none() {
|
||||
let view = Id::new_v4();
|
||||
commands.push(Command::CreateDiagram(Diagram {
|
||||
id: view,
|
||||
alias: self.project().fresh_alias("engineering_view", "view", view),
|
||||
name: format!("{} {preferred}", self.layer),
|
||||
name: format!("{} {preferred}", creation_layer),
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
layer: creation_layer,
|
||||
projection: preferred,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
@@ -4728,7 +4696,13 @@ impl App {
|
||||
));
|
||||
}
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
let changed_view = self.active_diagram != diagram_target;
|
||||
self.active_diagram = diagram_target;
|
||||
if changed_view
|
||||
&& let Some(view) = diagram_target.and_then(|id| self.project().diagrams.get(&id))
|
||||
{
|
||||
self.layer = view.layer;
|
||||
}
|
||||
self.selected = id;
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.refresh_draft();
|
||||
@@ -4742,7 +4716,7 @@ impl App {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship(
|
||||
self.project(),
|
||||
self.selected,
|
||||
self.layer,
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer),
|
||||
));
|
||||
}
|
||||
fn add_trace(&mut self) {
|
||||
@@ -5163,8 +5137,30 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
// Admission belongs to the caller: palette choice, contextual source, or
|
||||
// an already admitted session whose complete context is still current.
|
||||
fn arm_connection(
|
||||
&mut self,
|
||||
context: crate::route_edit::Context,
|
||||
tool: crate::connection_tool::Tool,
|
||||
) -> crate::route_edit::Context {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = crate::route_edit::Context {
|
||||
epoch: self.canvas_epoch,
|
||||
..context
|
||||
};
|
||||
self.connection = Some(crate::connection_edit::Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
context
|
||||
}
|
||||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||||
use crate::connection_edit::{Event, Session};
|
||||
use crate::connection_edit::Event;
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
@@ -5180,10 +5176,13 @@ impl App {
|
||||
}
|
||||
match event {
|
||||
Event::Begin(tool, source) => {
|
||||
self.edit_connection(Event::Choose(Some(tool)));
|
||||
if let Some(context) = self.route_context() {
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
if !crate::connection_edit::can_begin(self.project(), context.diagram, tool, source)
|
||||
{
|
||||
self.status = "Choose a visible compatible source in this diagram.".into();
|
||||
return;
|
||||
}
|
||||
let context = self.arm_connection(context, tool);
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
}
|
||||
Event::Choose(Some(tool)) => {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
@@ -5197,16 +5196,7 @@ impl App {
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.route_context().unwrap();
|
||||
self.connection = Some(Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
self.arm_connection(context, tool);
|
||||
}
|
||||
Event::Endpoint(captured, id) => {
|
||||
if captured != context {
|
||||
@@ -5267,17 +5257,9 @@ impl App {
|
||||
&project_view(self.project(), context.diagram),
|
||||
id,
|
||||
);
|
||||
if session.tool == crate::connection_tool::Tool::Allocation
|
||||
&& self.project().diagrams[&context.diagram].projection
|
||||
== ProjectionKind::Traceability
|
||||
{
|
||||
// Mixed views can allocate either perspective. Choosing
|
||||
// a visible source must not navigate to its preferred view.
|
||||
self.selected = id;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
} else {
|
||||
self.select(id);
|
||||
if !self.inspect_canvas_target(id) {
|
||||
self.connection = Some(session);
|
||||
return;
|
||||
}
|
||||
self.status = if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||||
@@ -5293,8 +5275,11 @@ impl App {
|
||||
Event::Restart(captured) => {
|
||||
if captured == context
|
||||
&& let Some(session) = &self.connection
|
||||
&& session.context == context
|
||||
{
|
||||
self.edit_connection(Event::Choose(Some(session.tool)));
|
||||
// A contextual mixed-layer tool need not be in this layer's
|
||||
// palette. Its unchanged session context retains admission.
|
||||
self.arm_connection(context, session.tool);
|
||||
}
|
||||
}
|
||||
Event::Choose(None) => {}
|
||||
|
||||
@@ -89,7 +89,10 @@ async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
Message::Select(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selected);
|
||||
|
||||
@@ -63,7 +63,6 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
@@ -238,7 +237,10 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
|
||||
));
|
||||
assert!(!events.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
Message::Select(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
)));
|
||||
send(&mut f.app, events);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selection);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compact neutral tool palette. Tool state never changes its allocated width.
|
||||
use super::*;
|
||||
use crate::connection_tool::Tool;
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
@@ -11,7 +12,7 @@ use iced::{
|
||||
#[derive(Clone, Copy)]
|
||||
enum Icon {
|
||||
Element(Kind),
|
||||
Arrow,
|
||||
Connection(Tool),
|
||||
Existing,
|
||||
Select,
|
||||
Collapse(bool),
|
||||
@@ -76,10 +77,59 @@ impl canvas::Program<Message> for Icon {
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Arrow => {
|
||||
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
|
||||
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
|
||||
}
|
||||
Self::Connection(connection) => match connection {
|
||||
// The OA and SA work-flow tools share a visual family; they
|
||||
// never coexist in a palette. Keep their small glyph legible.
|
||||
Tool::Interaction | Tool::FunctionalExchange => {
|
||||
for x in [1., 11.] {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(
|
||||
Point::new(x, 5.),
|
||||
Size::new(4., 6.),
|
||||
1.5.into(),
|
||||
),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(5., 8.), (10., 8.)]), stroke);
|
||||
f.stroke(&line(&[(7.5, 5.5), (10., 8.), (7.5, 10.5)]), stroke);
|
||||
}
|
||||
Tool::CommunicationMean => {
|
||||
// Participant endpoints, without implying a fixed direction.
|
||||
for x in [1., 11.] {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(x, 4.), Size::new(4., 8.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(5., 8.), (11., 8.)]), stroke);
|
||||
}
|
||||
Tool::BoundaryExchange => {
|
||||
// Ports on facing participant boundaries. Standard exchanges
|
||||
// are unoriented, so the generic tool has no arrowhead.
|
||||
for x in [2., 14.] {
|
||||
f.stroke(&line(&[(x, 2.), (x, 14.)]), stroke);
|
||||
}
|
||||
for x in [1., 12.] {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(x, 6.), Size::new(3., 4.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(4., 8.), (12., 8.)]), stroke);
|
||||
}
|
||||
Tool::Allocation => {
|
||||
// Work enters a performer: allocation can appear as nesting
|
||||
// rather than a relationship line in allocated-work views.
|
||||
f.stroke(&line(&[(1., 2.), (1., 14.), (15., 14.), (15., 2.)]), stroke);
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(Point::new(5., 9.), Size::new(6., 3.), 1.into()),
|
||||
stroke,
|
||||
);
|
||||
f.stroke(&line(&[(8., 1.), (8., 7.)]), stroke);
|
||||
f.stroke(&line(&[(5., 4.), (8., 7.), (11., 4.)]), stroke);
|
||||
}
|
||||
},
|
||||
Self::Existing => {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
|
||||
@@ -234,7 +284,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
}
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Arrow,
|
||||
Icon::Connection(*connection),
|
||||
connection.label(),
|
||||
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
|
||||
app.connection
|
||||
|
||||
@@ -67,7 +67,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -221,7 +221,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
@@ -300,7 +299,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -179,7 +179,7 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert!(app.delete_pending, "old token does not cancel new dialog");
|
||||
// Keep selection and epoch unchanged while moving to another Scenario owner.
|
||||
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
|
||||
let captured = app.scenario_context().unwrap();
|
||||
assert_eq!(captured.scenario, owners[0]);
|
||||
assert_eq!(
|
||||
@@ -197,28 +197,9 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
|
||||
assert_eq!(other.epoch, captured.epoch);
|
||||
assert_eq!(app.selected, uses[0]);
|
||||
assert!(
|
||||
!app.delete_target.as_ref().unwrap().snapshot.valid(&app),
|
||||
"the real captured snapshot rejects the changed Scenario destination"
|
||||
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
|
||||
"the independent owner guard must reject an otherwise current token"
|
||||
);
|
||||
// Isolate the independent behavior-owner predicate: a current, unqualified
|
||||
// object request has no old Scenario envelope or destination to reject it.
|
||||
// This test-only replacement does not refresh the captured behavior owner.
|
||||
let otherwise_current = crate::model_menu::Opened::new(
|
||||
&app,
|
||||
crate::model_menu::Request::object(uses[0], Point::ORIGIN),
|
||||
token,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(otherwise_current.valid(&app));
|
||||
let target = app.delete_target.as_ref().unwrap();
|
||||
assert_eq!(target.selection, app.selected);
|
||||
assert_eq!(target.placement, None);
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert_eq!(target.behavior_owner, Some(owners[0]));
|
||||
assert_eq!(diagram_removal::behavior_owner(&app), Some(owners[1]));
|
||||
assert!(app.delete_pending);
|
||||
assert!(!app.behavior.has_pending() && !app.source_dirty() && !app.draft_dirty());
|
||||
app.delete_target.as_mut().unwrap().snapshot = otherwise_current;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
//! Canvas inspection retains the displayed mixed-layer surface; navigation follows the model.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu::{Event as MenuEvent, Request};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
process: Id,
|
||||
placement: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let process = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Operational process", None)
|
||||
.unwrap();
|
||||
let capability = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Operational capability", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Enables",
|
||||
process,
|
||||
capability,
|
||||
);
|
||||
p.ensure_diagram(Layer::Oa);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let placement = p.place(diagram, process, Position { x: 0., y: 0. });
|
||||
p.place(diagram, capability, Position { x: 500., y: 0. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(app.project(), diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == edge)
|
||||
);
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
process,
|
||||
placement,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_surface(f: &Fixture) {
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_projection_switch_restores_all_authored_content_and_one_step_history() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Projection(ProjectionKind::Allocation));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(project_view(f.app.project(), f.diagram).nodes.is_empty());
|
||||
assert_eq!(f.app.project().diagrams[&f.diagram].nodes.len(), 2);
|
||||
let switched = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &switched.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &switched.bundle);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Projection(ProjectionKind::Traceability));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert!(
|
||||
project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == f.edge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_edge_and_group_inspection_preserve_surface_and_history() {
|
||||
for grouped in [false, true] {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
if grouped {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Placement(f.placement),
|
||||
multiselect::Choice::Replace,
|
||||
)));
|
||||
}
|
||||
let message = if grouped {
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(f.edge),
|
||||
multiselect::Choice::Add,
|
||||
))
|
||||
} else {
|
||||
Message::CanvasSelect(f.app.route_context().unwrap(), f.edge)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(f.edge))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets().len(),
|
||||
if grouped { 2 } else { 1 }
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(f: &mut Fixture, object: Id, placement: Option<Id>, action: Message) {
|
||||
let mut request = Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = placement;
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
|
||||
let token = f.app.model_menu.as_ref().unwrap().token;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(MenuEvent::Invoke(token, Box::new(action))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_context_properties_and_hide_keep_the_captured_surface() {
|
||||
let mut f = fixture();
|
||||
let edge = f.edge;
|
||||
invoke(&mut f, edge, None, Message::EditProperties(edge));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, edge);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let before = f.app.document.snapshot();
|
||||
let process = f.process;
|
||||
let placement = f.placement;
|
||||
invoke(&mut f, process, Some(placement), Message::HideSelected);
|
||||
assert_surface(&f);
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&placement)
|
||||
);
|
||||
assert!(f.app.project().elements.contains_key(&process));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() {
|
||||
let mut f = fixture();
|
||||
let process = f.process;
|
||||
let edge = f.edge;
|
||||
invoke(&mut f, edge, None, Message::Select(process));
|
||||
assert_eq!(f.app.selected, process);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_navigation_to_the_inspected_cross_layer_object_follows_its_layer() {
|
||||
let mut f = fixture();
|
||||
let _ = f.app.update(Message::CanvasSelect(
|
||||
f.app.route_context().unwrap(),
|
||||
f.edge,
|
||||
));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Select(f.edge));
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_invalid_draft_and_stale_packets_preserve_selection() {
|
||||
let mut f = fixture();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::Field("/id".into(), "-".into()));
|
||||
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_surface(&f);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(f.edge),
|
||||
multiselect::Choice::Replace,
|
||||
)));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
let _ = f.app.update(Message::RevertProperties);
|
||||
f.app.canvas_epoch += 1;
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
port,
|
||||
owner,
|
||||
"Output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(port, element);
|
||||
p.ensure_diagram(Layer::Sa);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let placement = p.place(diagram, owner, Position { x: 0., y: 0. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(app.project(), diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == port)
|
||||
);
|
||||
let _ = app.update(Message::CanvasSelect(app.route_context().unwrap(), port));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Port { port, placement })
|
||||
);
|
||||
// Reset inspection so the endpoint path cannot pass by selecting the same ID.
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
let context = app.route_context().unwrap();
|
||||
// Mixed canvases can retain a tool from either perspective.
|
||||
app.connection = Some(crate::connection_edit::Session::new(
|
||||
app.project(),
|
||||
&project_view(app.project(), diagram),
|
||||
context,
|
||||
crate::connection_tool::Tool::FunctionalExchange,
|
||||
));
|
||||
let _ = app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(context, port),
|
||||
));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
fn authoring_fixture(performer: bool) -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let owner = project
|
||||
.add_element(
|
||||
if performer {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::Activity
|
||||
},
|
||||
Layer::Oa,
|
||||
"Operational owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational target", None)
|
||||
.unwrap();
|
||||
let placement = project.place(f.diagram, owner, Position { x: 0., y: 400. });
|
||||
project.place(f.diagram, target, Position { x: 500., y: 400. });
|
||||
f.app = App::with_project(project);
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.layer = Layer::Sa;
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.selected = f.diagram;
|
||||
f.app.refresh_draft();
|
||||
f.app.reconcile_multiselect();
|
||||
f.process = owner;
|
||||
f.placement = placement;
|
||||
(f, target)
|
||||
}
|
||||
|
||||
fn assert_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_relationship_actions_keep_operational_source_and_commit_one_step() {
|
||||
for context_action in [false, true] {
|
||||
let (mut f, target) = authoring_fixture(false);
|
||||
let before = f.app.document.snapshot();
|
||||
let owner = f.process;
|
||||
let placement = f.placement;
|
||||
if context_action {
|
||||
invoke(
|
||||
&mut f,
|
||||
owner,
|
||||
Some(placement),
|
||||
Message::AddRelationKind(RelationKind::Interaction, false),
|
||||
);
|
||||
} else {
|
||||
let _ = f.app.update(Message::SelectNode(placement));
|
||||
let _ = f.app.update(Message::AddRelation);
|
||||
}
|
||||
assert_surface(&f);
|
||||
let Some(crate::workflow::Workflow::Relation(relation)) = &f.app.workflow else {
|
||||
panic!("relationship form did not open: {}", f.app.status);
|
||||
};
|
||||
assert_eq!(
|
||||
relation.source.as_ref().map(|source| source.id),
|
||||
Some(owner)
|
||||
);
|
||||
assert_eq!(relation.kind, RelationKind::Interaction);
|
||||
let saved = f
|
||||
.app
|
||||
.workflow
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
.unwrap();
|
||||
assert_eq!(saved["Relation"]["layer"], "Oa");
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Workflow(crate::workflow::Event::Target(Choice {
|
||||
id: target,
|
||||
label: "Operational target".into(),
|
||||
})));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert!(f.app.workflow.is_none(), "{}", f.app.status);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1);
|
||||
assert_eq!(
|
||||
(created[0].kind(), created[0].source(), created[0].target()),
|
||||
(RelationKind::Interaction, owner, target)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_add_child_command_uses_parent_layer_for_containment_and_allocation() {
|
||||
for performer in [false, true] {
|
||||
let (mut f, _) = authoring_fixture(performer);
|
||||
let before = f.app.document.snapshot();
|
||||
let owner = f.process;
|
||||
let placement = f.placement;
|
||||
invoke(
|
||||
&mut f,
|
||||
owner,
|
||||
Some(placement),
|
||||
Message::CreateContextElement(Kind::Activity, true),
|
||||
);
|
||||
assert_surface(&f);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "{}", f.app.status);
|
||||
let child = created[0].id;
|
||||
assert_eq!(created[0].kind(), Kind::Activity);
|
||||
assert_eq!(created[0].layer(), Layer::Oa);
|
||||
assert_eq!(
|
||||
created[0].parent,
|
||||
if performer { None } else { Some(owner) }
|
||||
);
|
||||
let allocations: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
|
||||
.collect();
|
||||
assert_eq!(allocations.len(), usize::from(performer));
|
||||
if performer {
|
||||
assert_eq!(
|
||||
(
|
||||
allocations[0].kind(),
|
||||
allocations[0].source(),
|
||||
allocations[0].target()
|
||||
),
|
||||
(RelationKind::Allocation, owner, child)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.element == child)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_root_creation_still_uses_the_active_perspective() {
|
||||
let (mut f, _) = authoring_fixture(false);
|
||||
let _ = f.app.update(Message::SelectNode(f.placement));
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CreateContextElement(Kind::Function, false));
|
||||
assert_surface(&f);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "{}", f.app.status);
|
||||
assert_eq!(created[0].kind(), Kind::Function);
|
||||
assert_eq!(created[0].layer(), Layer::Sa);
|
||||
assert_eq!(created[0].parent, None);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() {
|
||||
for existing_preferred in [false, true] {
|
||||
let (mut f, _) = authoring_fixture(false);
|
||||
let mut project = f.app.project().clone();
|
||||
for diagram in project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.filter(|d| d.layer == Layer::Oa)
|
||||
{
|
||||
diagram.projection = if existing_preferred {
|
||||
ProjectionKind::Allocation
|
||||
} else {
|
||||
ProjectionKind::Overview
|
||||
};
|
||||
}
|
||||
project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Capabilities;
|
||||
f.app = App::with_project(project);
|
||||
let _ = f.app.update(Message::Select(f.process));
|
||||
let _ = f.app.update(Message::Diagram(Choice {
|
||||
id: f.diagram,
|
||||
label: "System capabilities".into(),
|
||||
}));
|
||||
assert_eq!(f.app.selected, f.process);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CreateContextElement(Kind::Activity, true));
|
||||
assert_eq!(f.app.layer, Layer::Oa, "{}", f.app.status);
|
||||
let diagram = f.app.active_diagram.unwrap();
|
||||
assert_ne!(diagram, f.diagram);
|
||||
assert_eq!(f.app.project().diagrams[&diagram].layer, Layer::Oa);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&diagram].projection,
|
||||
ProjectionKind::Allocation
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams.len(),
|
||||
before.decoded.project.diagrams.len() + usize::from(!existing_preferred)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().elements[&f.app.selected].parent,
|
||||
Some(f.process)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// Regression scope: a contextual click may accept a valid property draft, but
|
||||
// must not inherit the draft object's semantic-layer navigation. If acceptance
|
||||
// changes the originating diagram identity or hides the clicked target, retain
|
||||
// that one accepted edit and refuse the old click. Do not infer a new target or
|
||||
// reinterpret an old surface packet on a replacement diagram.
|
||||
fn draft_click_fixture() -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let capability = project
|
||||
.add_element(Kind::Capability, Layer::Oa, "Second capability", None)
|
||||
.unwrap();
|
||||
let other_edge = project.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Also enables",
|
||||
f.process,
|
||||
capability,
|
||||
);
|
||||
project.place(f.diagram, capability, Position { x: 500., y: 300. });
|
||||
f.app = App::with_project(project);
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.layer = Layer::Sa;
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.selected = f.diagram;
|
||||
f.app.refresh_draft();
|
||||
f.app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == other_edge)
|
||||
);
|
||||
(f, other_edge)
|
||||
}
|
||||
|
||||
fn expected_draft_commit(app: &App, command: Command) -> Document {
|
||||
let mut expected = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
expected.execute(command).unwrap();
|
||||
expected
|
||||
}
|
||||
|
||||
fn assert_accepted_draft(app: &App, expected: &Document, revision: u64) {
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
assert_source_pair(app);
|
||||
}
|
||||
|
||||
fn assert_one_draft_history(
|
||||
app: &mut App,
|
||||
before: &oa_core::source_project::CompiledSourceProject,
|
||||
expected: &Document,
|
||||
) {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"the click must not add a second edit"
|
||||
);
|
||||
assert!(app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
fn pending_edge_name(f: &mut Fixture) {
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_surface(f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/name".into(), "Reviewed enables".into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(f.app.draft.as_ref().unwrap().command().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cross_layer_draft_then_canvas_edge_retains_surface_and_one_edit() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
// Capture after drafting, so this exercises successful draft application,
|
||||
// not refusal of a packet made stale by the property edit itself.
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, other_edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(other_edge))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cross_layer_draft_then_node_click_retains_surface_and_one_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::SelectNode(f.placement));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.process);
|
||||
assert_eq!(f.app.selected_node, Some(f.placement));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Placement(f.placement))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_reidentity_draft_commits_but_refuses_the_old_surface_click() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let replacement = Id::new_v4();
|
||||
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert!(!f.app.project().diagrams.contains_key(&context.diagram));
|
||||
assert!(
|
||||
f.app
|
||||
.active_diagram
|
||||
.is_some_and(|id| f.app.project().diagrams.contains_key(&id)),
|
||||
"never restore a removed diagram identity"
|
||||
);
|
||||
assert_ne!(f.app.selected, other_edge, "refuse the old surface click");
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_draft_commits_but_refuses_the_now_hidden_canvas_target() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
|
||||
diagram.projection = ProjectionKind::Allocation;
|
||||
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
|
||||
let _ = f.app.update(Message::FieldShape(
|
||||
"/projection".into(),
|
||||
serde_json::to_value(ProjectionKind::Allocation).unwrap(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert!(
|
||||
!project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == other_edge)
|
||||
);
|
||||
assert_ne!(
|
||||
f.app.selected, other_edge,
|
||||
"do not inspect a no-longer-visible hit"
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cross_layer_draft_blocks_contextual_clicks_without_changing_history() {
|
||||
for click in 0..3 {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), "not-a-uuid".into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let expected_error = f.app.draft.as_ref().unwrap().command().unwrap_err();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let message = match click {
|
||||
0 => Message::CanvasSelect(f.app.route_context().unwrap(), other_edge),
|
||||
1 => Message::SelectNode(f.placement),
|
||||
_ => Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(other_edge),
|
||||
multiselect::Choice::Add,
|
||||
)),
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
if click < 2 {
|
||||
assert_eq!(f.app.status, expected_error);
|
||||
} else {
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Finish or cancel the active edit before changing the diagram selection."
|
||||
);
|
||||
}
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selection.primary()
|
||||
);
|
||||
if click == 0 {
|
||||
// Pre-dispatch stale rejection applies no draft and must not emit
|
||||
// either success feedback or a new validation message.
|
||||
let stale = f.app.route_context().unwrap();
|
||||
f.app.canvas_epoch += 1;
|
||||
f.app.status = "Keep previous feedback".into();
|
||||
let _ = f.app.update(Message::CanvasSelect(stale, other_edge));
|
||||
assert_eq!(f.app.status, "Keep previous feedback");
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_select_after_valid_cross_layer_draft_still_navigates_and_commits_once() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::Select(other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.selected, other_edge);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_on_cross_layer_edge_retains_surface_and_one_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.status, "Properties applied");
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(f.edge))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_honors_an_intentional_active_diagram_layer_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
|
||||
diagram.layer = Layer::Oa;
|
||||
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
|
||||
let _ = f.app.update(Message::FieldShape(
|
||||
"/layer".into(),
|
||||
serde_json::to_value(Layer::Oa).unwrap(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(
|
||||
f.app.layer,
|
||||
Layer::Oa,
|
||||
"honor the edited diagram's own layer"
|
||||
);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.diagram);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_follows_the_known_active_diagram_reidentity_once() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let replacement = Id::new_v4();
|
||||
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
// This identity was explicitly authored in the diagram's own draft. No
|
||||
// queued click is being remapped or inferred from semantic similarity.
|
||||
assert_eq!(f.app.active_diagram, Some(replacement));
|
||||
assert_eq!(f.app.selected, replacement);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, replacement);
|
||||
assert!(!f.app.project().diagrams.contains_key(&f.diagram));
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Test-only proposal pinned to peer 6520a89dc042dd433454f6cc63cb23b0e6df7911.
|
||||
// Independent App test module; real popup messages retain menu tokens.
|
||||
use super::*;
|
||||
use crate::model_menu::{Event as MenuEvent, Request};
|
||||
|
||||
struct BeginFixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
surface_layer: Layer,
|
||||
source: Id,
|
||||
target: Id,
|
||||
wrong_layer: Id,
|
||||
hidden: Id,
|
||||
tool: crate::connection_tool::Tool,
|
||||
}
|
||||
|
||||
fn begin_fixture(source_layer: Layer, allocation: bool, mixed: bool) -> BeginFixture {
|
||||
use crate::connection_tool::Tool;
|
||||
let mut p = Project::empty();
|
||||
let (work, performer, exchange) = match source_layer {
|
||||
Layer::Oa => (Kind::Activity, Kind::Entity, Tool::Interaction),
|
||||
Layer::Sa => (Kind::Function, Kind::System, Tool::FunctionalExchange),
|
||||
};
|
||||
let opposite = if source_layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let surface_layer = if mixed { opposite } else { source_layer };
|
||||
let source = p
|
||||
.add_element(
|
||||
if allocation { performer } else { work },
|
||||
source_layer,
|
||||
"Source",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let target = p.add_element(work, source_layer, "Target", None).unwrap();
|
||||
let hidden = p
|
||||
.add_element(work, source_layer, "Hidden target", None)
|
||||
.unwrap();
|
||||
let other_work = if opposite == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let wrong_layer = p
|
||||
.add_element(other_work, opposite, "Wrong layer target", None)
|
||||
.unwrap();
|
||||
p.ensure_diagram(source_layer);
|
||||
let diagram = p.ensure_diagram(surface_layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = if mixed {
|
||||
ProjectionKind::Traceability
|
||||
} else {
|
||||
ProjectionKind::Capabilities
|
||||
};
|
||||
p.place(diagram, source, Position { x: 0., y: 0. });
|
||||
p.place(diagram, target, Position { x: 400., y: 0. });
|
||||
if mixed {
|
||||
p.place(diagram, wrong_layer, Position { x: 800., y: 0. });
|
||||
}
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = surface_layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(!app.document.can_undo());
|
||||
BeginFixture {
|
||||
app,
|
||||
diagram,
|
||||
surface_layer,
|
||||
source,
|
||||
target,
|
||||
wrong_layer,
|
||||
hidden,
|
||||
tool: if allocation {
|
||||
Tool::Allocation
|
||||
} else {
|
||||
exchange
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_surface(f: &BeginFixture) {
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, f.surface_layer);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
fn begin_popup_action(f: &mut BeginFixture) -> Option<Message> {
|
||||
begin_popup_action_from(f, true)
|
||||
}
|
||||
|
||||
fn begin_popup_action_from(f: &mut BeginFixture, canvas: bool) -> Option<Message> {
|
||||
let mut request = Request::object(f.source, iced::Point::ORIGIN);
|
||||
if canvas {
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == f.source)
|
||||
.map(|node| node.id);
|
||||
}
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
|
||||
let popup = crate::model_menu::popup(&f.app).expect("real model popup");
|
||||
let expected = format!("Draw {}", f.tool.label().to_lowercase());
|
||||
for entry in popup.entries {
|
||||
if let crate::context_menu::Entry::Action { label, message, .. } = entry
|
||||
&& label == expected
|
||||
{
|
||||
if let Some(Message::Menu(MenuEvent::Invoke(token, _))) = &message {
|
||||
assert_eq!(*token, popup.token);
|
||||
} else {
|
||||
assert!(message.is_none(), "Draw must use the menu token envelope");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
None // Omission is also a valid unavailable-action policy.
|
||||
}
|
||||
|
||||
fn begin_from_popup(f: &mut BeginFixture) -> Message {
|
||||
begin_from_popup_origin(f, true)
|
||||
}
|
||||
|
||||
fn begin_from_popup_origin(f: &mut BeginFixture, canvas: bool) -> Message {
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let action =
|
||||
begin_popup_action_from(f, canvas).expect("visible semantic source offers enabled Draw");
|
||||
let _ = f.app.update(action.clone());
|
||||
begin_surface(f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let session = f
|
||||
.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.expect("menu Draw must arm its tool");
|
||||
assert_eq!(session.tool, f.tool);
|
||||
assert_eq!(session.source, Some(f.source));
|
||||
assert_eq!(session.context, f.app.route_context().unwrap());
|
||||
assert!(session.candidates.contains(&f.target));
|
||||
assert!(!session.candidates.contains(&f.wrong_layer));
|
||||
assert!(!session.candidates.contains(&f.hidden));
|
||||
action
|
||||
}
|
||||
|
||||
fn begin_complete_and_check_history(f: &mut BeginFixture) {
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(context, f.target),
|
||||
));
|
||||
begin_surface(f);
|
||||
assert!(f.app.connection.is_none());
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let relations: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| !before.decoded.project.relations.contains_key(&r.id))
|
||||
.collect();
|
||||
assert_eq!(relations.len(), 1);
|
||||
let expected = match f.tool {
|
||||
crate::connection_tool::Tool::Interaction => RelationKind::Interaction,
|
||||
crate::connection_tool::Tool::FunctionalExchange => RelationKind::FunctionalExchange,
|
||||
crate::connection_tool::Tool::Allocation => RelationKind::Allocation,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(relations[0].kind(), expected);
|
||||
assert_eq!(
|
||||
(relations[0].source(), relations[0].target()),
|
||||
(f.source, f.target)
|
||||
);
|
||||
// Random identities are accepted only for the precise command delta.
|
||||
// Everything else, including traces, metadata and view options, stays exact.
|
||||
let mut expected_project = before.decoded.project.clone();
|
||||
let created = relations[0];
|
||||
assert!(
|
||||
expected_project
|
||||
.relations
|
||||
.insert(created.id, created.clone())
|
||||
.is_none()
|
||||
);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &created.body
|
||||
{
|
||||
assert_eq!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
|
||||
assert!(items.is_empty());
|
||||
assert_ne!(source_port, target_port);
|
||||
let output = &f.app.project().elements[source_port];
|
||||
let input = &f.app.project().elements[target_port];
|
||||
assert_eq!(
|
||||
(output.kind(), output.layer(), output.parent),
|
||||
(Kind::Port, Layer::Sa, Some(f.source))
|
||||
);
|
||||
assert_eq!(
|
||||
(input.kind(), input.layer(), input.parent),
|
||||
(Kind::Port, Layer::Sa, Some(f.target))
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(output), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items.is_empty())
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(input), Some(PortContract::FunctionInput { incoming_items }) if incoming_items.is_empty())
|
||||
);
|
||||
assert!(!before.decoded.project.elements.contains_key(source_port));
|
||||
assert!(!before.decoded.project.elements.contains_key(target_port));
|
||||
assert_eq!(output.id, *source_port);
|
||||
assert_eq!(input.id, *target_port);
|
||||
expected_project
|
||||
.elements
|
||||
.insert(*source_port, output.clone());
|
||||
expected_project
|
||||
.elements
|
||||
.insert(*target_port, input.clone());
|
||||
assert_eq!(
|
||||
f.app.project().elements.len(),
|
||||
before.decoded.project.elements.len() + 2
|
||||
);
|
||||
} else {
|
||||
assert_ne!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
|
||||
assert_eq!(f.app.project().elements, before.decoded.project.elements);
|
||||
}
|
||||
assert_eq!(f.app.project(), &expected_project);
|
||||
assert_eq!(f.app.project().diagrams, before.decoded.project.diagrams);
|
||||
for (id, element) in &before.decoded.project.elements {
|
||||
assert_eq!(&f.app.project().elements[id], element);
|
||||
}
|
||||
begin_assert_source_pair(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!f.app.document.can_undo(),
|
||||
"creation is a single history step"
|
||||
);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &after.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
begin_assert_source_pair(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_exchange_uses_actual_menu_in_both_mixed_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
begin_from_popup(&mut f);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_cancel_restart_and_stale_menu_preserve_session_ownership() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
let original = f.app.document.snapshot();
|
||||
let stale_menu = begin_from_popup(&mut f);
|
||||
let first = f.app.route_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Connection(crate::connection_edit::Event::Restart(
|
||||
first,
|
||||
)));
|
||||
begin_surface(&f);
|
||||
let restarted = f
|
||||
.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.expect("restart retains contextual tool");
|
||||
assert_eq!(restarted.tool, f.tool);
|
||||
assert_eq!(restarted.source, None);
|
||||
assert!(restarted.candidates.contains(&f.source));
|
||||
let current = restarted.context;
|
||||
assert_ne!(current, first);
|
||||
let _ = f.app.update(stale_menu);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(first, f.target),
|
||||
));
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().source, None);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(current, f.source),
|
||||
));
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().source, Some(f.source));
|
||||
let _ = f.app.update(Message::Escape);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(current, f.target),
|
||||
));
|
||||
assert!(f.app.connection.is_none());
|
||||
begin_surface(&f);
|
||||
assert_eq!(f.app.project(), &original.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &original.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_allocation_uses_actual_menu_in_both_mixed_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, true, true);
|
||||
begin_from_popup(&mut f);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_allocation_is_not_enabled_when_projection_hides_work() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, true, false);
|
||||
let view = project_view(f.app.project(), f.diagram);
|
||||
assert!(view.nodes.iter().any(|n| n.element == f.source));
|
||||
assert!(!view.nodes.iter().any(|n| n.element == f.target));
|
||||
assert!(!f.tool.available_in(layer, ProjectionKind::Capabilities));
|
||||
let before = f.app.document.snapshot();
|
||||
assert!(
|
||||
begin_popup_action(&mut f).is_none(),
|
||||
"Draw allocation must be disabled or omitted when work is not projected"
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Close));
|
||||
// Defensive handler refusal complements actual-menu enabledness above.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Connection(crate::connection_edit::Event::Begin(
|
||||
f.tool, f.source,
|
||||
)));
|
||||
assert!(f.app.connection.is_none());
|
||||
begin_surface(&f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_assert_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_explorer_draw_retains_visible_mixed_diagram_in_both_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
// A real Explorer Request::object has no canvas/placement envelope,
|
||||
// but its generated Draw action still targets the visible current view.
|
||||
begin_from_popup_origin(&mut f, false);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,6 @@ pub struct Confirmation {
|
||||
pub selection: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub behavior_owner: Option<Id>,
|
||||
pub(crate) scenario: Option<super::scenario_removal::Review>,
|
||||
pub(crate) process: Option<super::process_removal::Review>,
|
||||
pub(crate) saved: Option<super::saved_removal::Review>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
@@ -212,9 +209,6 @@ impl App {
|
||||
selection: self.selected,
|
||||
placement: placement(self),
|
||||
behavior_owner: behavior_owner(self),
|
||||
scenario: None,
|
||||
process: None,
|
||||
saved: None,
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Keep overlay ownership ahead of cancellation and captured reorder Apply.
|
||||
//! Keep overlay Escape ahead of modal cancellation in the ordinary widget tree.
|
||||
//!
|
||||
//! Iced skips root.update when an overlay captures an event. Keeping this
|
||||
//! wrapper present even when inactive preserves the child's widget state. A
|
||||
@@ -24,34 +24,9 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
&& app.architecture.is_none()));
|
||||
Element::new(Bridge {
|
||||
content,
|
||||
removal_cancel: app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| {
|
||||
app.delete_pending && (target.process.is_some() || target.saved.is_some())
|
||||
})
|
||||
.map(|target| {
|
||||
if target.saved.is_some() {
|
||||
return Message::SavedRemoval(Box::new(super::saved_removal::Event::Cancel {
|
||||
token: target.snapshot.token,
|
||||
}));
|
||||
}
|
||||
Message::ProcessRemoval(Box::new(super::process_removal::Event::Cancel {
|
||||
token: target.snapshot.token,
|
||||
}))
|
||||
}),
|
||||
reorder_active: app.scenario_reorder_enabled() && app.scenario_reorder.session.is_some(),
|
||||
reorder_apply: crate::behavior::scenario::reorder_view::apply_message(app),
|
||||
messages: [
|
||||
architecture_authoring::captured_escape_message(app),
|
||||
modal.then_some(Message::Escape).or_else(|| {
|
||||
app.scenario_reorder.session.as_ref().map(|session| {
|
||||
crate::behavior::scenario::reorder_view::message(
|
||||
session,
|
||||
super::scenario_reorder::Intent::Cancel,
|
||||
)
|
||||
})
|
||||
}),
|
||||
modal.then_some(Message::Escape),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -59,9 +34,6 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
struct Bridge<'a, R> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
messages: [Option<Message>; 2],
|
||||
removal_cancel: Option<Message>,
|
||||
reorder_active: bool,
|
||||
reorder_apply: Option<Message>,
|
||||
}
|
||||
|
||||
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
@@ -115,43 +87,6 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
if self.reorder_active
|
||||
&& matches!(event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Enter), modifiers, .. })
|
||||
if modifiers.control() && !modifiers.alt() && !modifiers.shift() && !modifiers.logo())
|
||||
{
|
||||
// A picker may have opened earlier in this same input batch, before
|
||||
// App state can invalidate the captured reorder action.
|
||||
if let Some(element) = self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let mut current = overlay::Nested::new(element);
|
||||
let node = current.layout(renderer, viewport.size());
|
||||
current.update(
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed { repeat: false, .. })
|
||||
) && let Some(message) = &self.reorder_apply
|
||||
{
|
||||
shell.publish(message.clone());
|
||||
}
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
let escape = matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
@@ -164,11 +99,6 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
// Escape would otherwise reach focused fields underneath it. Offer only
|
||||
// Escape to the current overlay before updating the ordinary tree.
|
||||
if escape {
|
||||
if let Some(cancel) = &self.removal_cancel {
|
||||
shell.publish(cancel.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if let Some(element) = self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
@@ -190,13 +120,6 @@ impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
if self.reorder_active
|
||||
&& let Some(cancel) = &self.messages[1]
|
||||
{
|
||||
shell.publish(cancel.clone());
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
|
||||
@@ -100,7 +100,6 @@ impl App {
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
self.topology_context(),
|
||||
self.behavior.topology.selection_address(),
|
||||
self.scenario_context(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
@@ -110,9 +109,6 @@ impl App {
|
||||
if let Some(current) = self.topology_context() {
|
||||
return Some(process_context(current));
|
||||
}
|
||||
if let Some(current) = self.scenario_context() {
|
||||
return Some(crate::behavior::scenario::selection::context(current));
|
||||
}
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
@@ -123,14 +119,6 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
if let Some(context) = self.scenario_context() {
|
||||
return crate::behavior::scenario::selection::visible(
|
||||
self.project(),
|
||||
context,
|
||||
self.selected,
|
||||
)
|
||||
.contains(target);
|
||||
}
|
||||
if let Some(context) = self.topology_context() {
|
||||
return self
|
||||
.behavior
|
||||
@@ -164,10 +152,6 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
// Scenario input additionally needs its representation and visit.
|
||||
if self.scenario_context().is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
@@ -235,10 +219,6 @@ impl App {
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
if self.scenario_context().is_some() {
|
||||
self.reconcile_scenario_multiselect();
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.reconcile_process_multiselect();
|
||||
return;
|
||||
@@ -304,10 +284,6 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn inspect_multiselect_primary(&mut self) {
|
||||
if self.scenario_context().is_some() {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
return;
|
||||
}
|
||||
if self.topology_context().is_some() {
|
||||
self.inspect_process_multiselect_primary();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
// Proposed tests against c7065db76ecca3faa733ccb5f09b87fd0a0958be.
|
||||
use super::*;
|
||||
|
||||
struct RelocationFixture {
|
||||
app: App,
|
||||
source: Id,
|
||||
target: Id,
|
||||
node: Id,
|
||||
other_node: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn relocation_fixture(source_layer: Layer, target_layer: Layer) -> RelocationFixture {
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observed work", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Other work", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Observed exchange", work, other);
|
||||
let source = p.ensure_diagram(source_layer);
|
||||
p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Traceability;
|
||||
let node = p.place(source, work, Position { x: 20., y: 20. });
|
||||
let other_node = p.place(source, other, Position { x: 500., y: 20. });
|
||||
let mut destination = p.diagrams[&source].clone();
|
||||
destination.id = Id::new_v4();
|
||||
destination.alias = "relocation_destination".into();
|
||||
destination.name = "Relocation destination".into();
|
||||
destination.layer = target_layer;
|
||||
destination.nodes.clear();
|
||||
let target = destination.id;
|
||||
p.diagrams.insert(target, destination);
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = source_layer;
|
||||
app.active_diagram = Some(source);
|
||||
app.selected = node;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
matches!(app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == source)
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
RelocationFixture {
|
||||
app,
|
||||
source,
|
||||
target,
|
||||
node,
|
||||
other_node,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn relocation_field(app: &mut App, path: &str, value: impl ToString) {
|
||||
let _ = app.update(Message::Field(path.into(), value.to_string()));
|
||||
}
|
||||
|
||||
fn relocation_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
fn relocation_selection(app: &App, diagram: Id, placement: Id) {
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, app.project().diagrams[&diagram].layer);
|
||||
assert_eq!(app.selected, placement);
|
||||
assert_eq!(app.selected_node, Some(placement));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.original, placement);
|
||||
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == diagram));
|
||||
}
|
||||
|
||||
fn relocation_expected(app: &App, command: Command) -> Document {
|
||||
let mut expected = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
expected.execute(command).unwrap();
|
||||
expected
|
||||
}
|
||||
|
||||
fn relocation_history(
|
||||
app: &mut App,
|
||||
before: &oa_core::source_project::CompiledSourceProject,
|
||||
expected: &Document,
|
||||
) {
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(!app.document.can_redo());
|
||||
relocation_source_pair(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_placement_relocation_follows_actual_owner_with_optional_new_identity() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for reidentify in [false, true] {
|
||||
let mut f = relocation_fixture(Layer::Oa, layer);
|
||||
let before = f.app.document.snapshot();
|
||||
let before_project = before.decoded.project.clone();
|
||||
let revision = f.app.document.revision();
|
||||
let id = if reidentify { Id::new_v4() } else { f.node };
|
||||
let mut expected = before_project.clone();
|
||||
let mut placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
placement.id = id;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.target)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, placement);
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::Batch(vec![
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: expected.diagrams[&f.target].nodes[&id].clone(),
|
||||
},
|
||||
Command::RelocateNode {
|
||||
node: id,
|
||||
diagram: f.target,
|
||||
},
|
||||
]),
|
||||
);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
relocation_field(&mut f.app, "/diagram", f.target);
|
||||
if reidentify {
|
||||
relocation_field(&mut f.app, "/id", id);
|
||||
}
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
relocation_selection(&f.app, f.target, id);
|
||||
relocation_source_pair(&f.app);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_owner_placement_position_alias_and_identity_keep_mixed_surface() {
|
||||
for different_current_view in [false, true] {
|
||||
for field in ["/position/x", "/alias", "/id"] {
|
||||
let mut f = relocation_fixture(Layer::Sa, Layer::Oa);
|
||||
// Ordinary Select plus the diagram picker can retain an independent
|
||||
// placement Inspector while showing a different saved view. This is
|
||||
// an App-handler workflow, not a claim of native menu execution.
|
||||
let _ = f.app.update(Message::Select(f.app.project().id));
|
||||
let _ = f.app.update(Message::Select(f.node));
|
||||
let current = if different_current_view {
|
||||
f.target
|
||||
} else {
|
||||
f.source
|
||||
};
|
||||
if different_current_view {
|
||||
let _ = f.app.update(Message::Diagram(Choice {
|
||||
id: current,
|
||||
label: "Other current view".into(),
|
||||
}));
|
||||
}
|
||||
assert_eq!(f.app.selected, f.node);
|
||||
assert_eq!(f.app.active_diagram, Some(current));
|
||||
assert!(
|
||||
matches!(f.app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == f.source)
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let mut placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
let value = match field {
|
||||
"/position/x" => {
|
||||
placement.position.x = 123.;
|
||||
"123".to_owned()
|
||||
}
|
||||
"/alias" => {
|
||||
placement.alias = "renamed_placement".into();
|
||||
placement.alias.clone()
|
||||
}
|
||||
_ => {
|
||||
placement.id = Id::new_v4();
|
||||
placement.id.to_string()
|
||||
}
|
||||
};
|
||||
let id = placement.id;
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: placement.clone(),
|
||||
},
|
||||
);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, placement);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
relocation_field(&mut f.app, field, value);
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.active_diagram, Some(current));
|
||||
assert_eq!(f.app.layer, f.app.project().diagrams[¤t].layer);
|
||||
assert_eq!(f.app.selected, id);
|
||||
assert_eq!(
|
||||
f.app.selected_node,
|
||||
if different_current_view {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
);
|
||||
let draft = f.app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.original, id);
|
||||
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == f.source));
|
||||
relocation_source_pair(&f.app);
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_relocation_refuses_queued_old_canvas_target_with_feedback() {
|
||||
for edge_hit in [false, true] {
|
||||
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::Batch(vec![
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: f.app.project().diagrams[&f.source].nodes[&f.node].clone(),
|
||||
},
|
||||
Command::RelocateNode {
|
||||
node: f.node,
|
||||
diagram: f.target,
|
||||
},
|
||||
]),
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.target)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(f.node, placement);
|
||||
let revision = f.app.document.revision();
|
||||
let captured = f.app.route_context().unwrap();
|
||||
relocation_field(&mut f.app, "/diagram", f.target);
|
||||
let message = if edge_hit {
|
||||
Message::CanvasSelect(captured, f.edge)
|
||||
} else {
|
||||
Message::SelectNode(f.other_node)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
relocation_selection(&f.app, f.target, f.node);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert!(
|
||||
f.app.multiselect_snapshot().is_none_or(|snapshot| {
|
||||
!snapshot.targets().iter().any(|target| {
|
||||
matches!(target, crate::diagram_usage::Target::Placement(id) if *id == f.other_node)
|
||||
|| matches!(target, crate::diagram_usage::Target::Edge(id) if *id == f.edge)
|
||||
})
|
||||
}),
|
||||
"the rejected old hit must not enter the destination selection"
|
||||
);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
relocation_source_pair(&f.app);
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_placement_destination_preserves_owner_model_source_and_draft() {
|
||||
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
relocation_field(&mut f.app, "/diagram", Id::new_v4());
|
||||
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
assert!(!f.app.apply());
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
relocation_selection(&f.app, f.source, f.node);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
//! One captured Process selection, one impact review, one document transaction.
|
||||
use super::multiselect::process_context;
|
||||
use super::*;
|
||||
use crate::behavior::topology::{self, group_removal};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Begin {
|
||||
input_generation: u64,
|
||||
context: topology::Context,
|
||||
snapshot: Box<Snapshot>,
|
||||
},
|
||||
Query {
|
||||
token: u64,
|
||||
query: String,
|
||||
},
|
||||
Page {
|
||||
token: u64,
|
||||
page: usize,
|
||||
},
|
||||
Cancel {
|
||||
token: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct Review {
|
||||
input_generation: u64,
|
||||
pub(crate) context: topology::Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) plan: group_removal::Plan,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
let input_generation = app.behavior.topology.removal_generation()?;
|
||||
let context = app.topology_context()?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
(snapshot.targets().len() > 1).then_some(Message::ProcessRemoval(Box::new(Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot: Box::new(snapshot),
|
||||
})))
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn process_removal_enabled(&self) -> bool {
|
||||
diagram_removal::enabled(self)
|
||||
&& !self.source_rename.open()
|
||||
&& self.behavior.topology.selecting()
|
||||
&& self.behavior.topology.removal_generation().is_some()
|
||||
&& self.topology_context().is_some()
|
||||
}
|
||||
|
||||
fn process_removal_matches(&self, context: topology::Context, snapshot: &Snapshot) -> bool {
|
||||
if self.topology_context() != Some(context)
|
||||
|| !self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selected| selected.matches(snapshot, process_context(context)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let visible = self
|
||||
.behavior
|
||||
.topology
|
||||
.selection_targets(self.project(), context.owner);
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
}
|
||||
|
||||
pub(super) fn process_removal_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot,
|
||||
} => {
|
||||
// Admission precedes every shared input/selection boundary. A
|
||||
// stale packet must not prune the set or retire a newer editor.
|
||||
if self.behavior.topology.removal_generation() != Some(input_generation)
|
||||
|| !self.process_removal_matches(context, &snapshot)
|
||||
|| !self.process_removal_enabled()
|
||||
|| snapshot.targets().len() < 2
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let plan =
|
||||
match group_removal::prepare(self.project(), context.owner, snapshot.targets())
|
||||
{
|
||||
Ok(plan) => plan,
|
||||
Err(reason) => {
|
||||
self.status = reason;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let Some(primary_target) = snapshot.primary().cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let primary = match &primary_target {
|
||||
Target::ProcessOccurrence(endpoint) => endpoint.occurrence,
|
||||
Target::ProcessLink(id) => *id,
|
||||
_ => return Task::none(),
|
||||
};
|
||||
self.source_interaction();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.topology_context().expect("admitted Process");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
|
||||
request.process = Some(Box::new(context));
|
||||
request.selection_target = Some(Box::new(primary_target));
|
||||
self.menu_sequence = self.menu_sequence.wrapping_add(1);
|
||||
self.delete_target = model_menu::Opened::new(self, request, self.menu_sequence)
|
||||
.map(|opened| diagram_removal::Confirmation {
|
||||
snapshot: opened,
|
||||
selection: self.selected,
|
||||
placement: None,
|
||||
behavior_owner: Some(context.owner),
|
||||
scenario: None,
|
||||
saved: None,
|
||||
process: Some(Review {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot,
|
||||
plan,
|
||||
query: String::new(),
|
||||
page: 0,
|
||||
}),
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
Event::Query { token, query } => {
|
||||
if let Some(target) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
&& let Some(review) = target.process.as_mut()
|
||||
{
|
||||
review.query = query;
|
||||
review.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page { token, page } => {
|
||||
if let Some(target) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
&& let Some(review) = target.process.as_mut()
|
||||
{
|
||||
review.page = page;
|
||||
}
|
||||
}
|
||||
Event::Cancel { token } => {
|
||||
if self.delete_target.as_ref().is_some_and(|target| {
|
||||
target.snapshot.token == token && target.process.is_some()
|
||||
}) {
|
||||
self.delete_target = None;
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Removal cancelled. Model and selection unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn process_removal_review_valid(
|
||||
&self,
|
||||
target: &diagram_removal::Confirmation,
|
||||
) -> bool {
|
||||
let Some(review) = &target.process else {
|
||||
return false;
|
||||
};
|
||||
self.delete_pending
|
||||
&& !self.busy
|
||||
&& target.snapshot.valid(self)
|
||||
&& self.behavior.topology.removal_generation() == Some(review.input_generation)
|
||||
&& self.process_removal_matches(review.context, &review.snapshot)
|
||||
&& self.selected == target.selection
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_rename.open()
|
||||
&& self.behavior.topology.selecting()
|
||||
}
|
||||
|
||||
pub(super) fn confirm_process_removal(&mut self, token: u64) {
|
||||
let Some(target) = self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| target.snapshot.token == token && target.process.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let valid = self.process_removal_review_valid(target);
|
||||
let target = self.delete_target.take().expect("captured review");
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if !valid {
|
||||
self.status =
|
||||
"Removal review expired. Nothing was removed; review the current selection again."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
let review = target.process.expect("Process review");
|
||||
match self.document.execute(review.plan.command) {
|
||||
Ok(_) => {
|
||||
self.selected = review.context.owner;
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(review.context.owner);
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.status = format!(
|
||||
"Removed {} selected Process contributions or links in one change. Referenced definitions preserved. Undo restores the model.",
|
||||
review.snapshot.targets().len()
|
||||
);
|
||||
}
|
||||
Err(error) => self.status = format!("Nothing removed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::topology::{CanvasEvent, CanvasTool, direct_tests};
|
||||
use oa_core::source_project::SourceBundle;
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
})
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: multiselect::Choice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
context, target, choice,
|
||||
)));
|
||||
}
|
||||
fn open_project(project: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.reconcile_multiselect();
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
app
|
||||
}
|
||||
fn fixture(layer: Layer) -> (App, direct_tests::Fixture) {
|
||||
let f = direct_tests::fixture(layer);
|
||||
let mut app = open_project(f.p.clone(), f.owner);
|
||||
choose(
|
||||
&mut app,
|
||||
Target::ProcessLink(f.link),
|
||||
multiselect::Choice::Replace,
|
||||
);
|
||||
choose(&mut app, local(f.nodes[0]), multiselect::Choice::Add);
|
||||
assert!(app.process_removal_enabled());
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets().len(), 2);
|
||||
(app, f)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ProcessRemoval(Box::new(event)));
|
||||
}
|
||||
fn open(app: &mut App) -> u64 {
|
||||
let message = begin(app).unwrap();
|
||||
let _ = app.update(message);
|
||||
let target = app.delete_target.as_ref().expect("aggregate review");
|
||||
assert!(app.process_removal_review_valid(target));
|
||||
target.snapshot.token
|
||||
}
|
||||
fn native_bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: [("project.arcadia".into(), source.into())]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
const BASELINE: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../harness/fixtures/process-removal-baseline-20260925.arcadia"
|
||||
));
|
||||
const REMOVED: &str = include_str!(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
// V1 assumed canonical whitespace. V2 independently retains removed-token
|
||||
// trivia and existing reference spelling under language section 7's rules.
|
||||
"/../../harness/fixtures/process-removal-removed-v2-20260925.arcadia"
|
||||
));
|
||||
fn native() -> App {
|
||||
let document = Document::from_bundle(native_bundle(BASELINE), Default::default()).unwrap();
|
||||
let owner = alias(document.project(), "assessment");
|
||||
let mut app = open_project(document.project().clone(), owner);
|
||||
app.document = document;
|
||||
// Replace the generated split workspace just as open_source_project does.
|
||||
// sync preserves discovered files and is not a new-document initializer.
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.sync();
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.source_workspace.bundle(), native_bundle(BASELINE));
|
||||
app
|
||||
}
|
||||
fn alias(project: &Project, name: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == name)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_literal_native_model_and_source_oracle_undoes_once() {
|
||||
let mut app = native();
|
||||
for (index, name) in ["a", "c", "approved"].iter().enumerate() {
|
||||
let id = alias(app.project(), name);
|
||||
let target = if *name == "approved" {
|
||||
Target::ProcessLink(id)
|
||||
} else {
|
||||
local(id)
|
||||
};
|
||||
choose(
|
||||
&mut app,
|
||||
target,
|
||||
if index == 0 {
|
||||
multiselect::Choice::Replace
|
||||
} else {
|
||||
multiselect::Choice::Add
|
||||
},
|
||||
);
|
||||
}
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selected.targets());
|
||||
assert_eq!(review.snapshot.primary(), selected.primary());
|
||||
let rows = view::matching_rows(app.project(), review);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.label.contains("@caller")
|
||||
&& row.disposition.contains("Uses changed definition"))
|
||||
);
|
||||
assert!(
|
||||
rows.iter().any(|row| row.label.contains("@outer")
|
||||
&& row.disposition.contains("Uses changed definition"))
|
||||
);
|
||||
assert!(rows.iter().any(|row| {
|
||||
row.label
|
||||
.contains("Implicit listed order; 1 adjacent ordered pairs")
|
||||
&& row.disposition.starts_with("After")
|
||||
}));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let expected = Document::from_bundle(native_bundle(REMOVED), Default::default()).unwrap();
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
expected.bundle(),
|
||||
"literal independently authored source oracle"
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), *expected.bundle());
|
||||
assert_eq!(app.selected, alias(app.project(), "assessment"));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
let removed = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_mixed_card_link_is_one_exact_transaction_in_both_layers() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut app, f) = fixture(layer);
|
||||
let before = app.document.snapshot();
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
review.plan.removed,
|
||||
[f.nodes[0], f.link].into_iter().collect()
|
||||
);
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
let mut expected = f.p.clone();
|
||||
expected.elements.remove(&f.nodes[0]);
|
||||
expected.relations.remove(&f.link);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.nodes[1], f.nodes[2]];
|
||||
assert_eq!(app.project(), &expected);
|
||||
for id in f.work.into_iter().chain([f.exchange]) {
|
||||
assert!(app.project().contains(id));
|
||||
}
|
||||
let removed = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
Document::from_bundle(removed.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_cancel_stale_packets_and_failed_commit_preserve_redo_source_and_selection()
|
||||
{
|
||||
for failure in [false, true] {
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
app.document
|
||||
.execute(Command::Rename(f.nodes[2], "Future name".into()))
|
||||
.unwrap();
|
||||
let future = app.document.bundle().clone();
|
||||
assert!(app.document.undo());
|
||||
app.sync();
|
||||
app.reconcile_multiselect();
|
||||
let baseline = app.document.snapshot();
|
||||
let source = app.source_editor.text();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let old_begin = begin(&app).unwrap();
|
||||
let first = open(&mut app);
|
||||
send(&mut app, Event::Cancel { token: first });
|
||||
let second = open(&mut app);
|
||||
let epoch = app.canvas_epoch;
|
||||
for message in [
|
||||
old_begin,
|
||||
Message::ConfirmDelete(first),
|
||||
Message::CancelDelete,
|
||||
Message::Escape,
|
||||
Message::ProcessRemoval(Box::new(Event::Cancel { token: first })),
|
||||
Message::ProcessRemoval(Box::new(Event::Query {
|
||||
token: first,
|
||||
query: "stale".into(),
|
||||
})),
|
||||
Message::ProcessRemoval(Box::new(Event::Page {
|
||||
token: first,
|
||||
page: 90,
|
||||
})),
|
||||
] {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.delete_target.as_ref().unwrap().snapshot.token, second);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
if failure {
|
||||
// Force a genuine atomic engine failure after the admitted deletion.
|
||||
// Neither the successful first candidate command nor history leaks.
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_mut()
|
||||
.unwrap();
|
||||
review.plan.command = Command::Batch(vec![
|
||||
Command::Delete(f.nodes[0]),
|
||||
Command::Delete(Id::new_v4()),
|
||||
]);
|
||||
let _ = app.update(Message::ConfirmDelete(second));
|
||||
assert!(app.status.starts_with("Nothing removed:"));
|
||||
} else {
|
||||
send(&mut app, Event::Cancel { token: second });
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert_eq!(app.source_workspace.bundle(), baseline.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert!(app.behavior.topology.camera_boundary() == camera);
|
||||
assert!(app.document.can_redo() && !app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &future);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_stale_secondary_and_context_are_rejected_before_any_boundary() {
|
||||
for change in 0..4 {
|
||||
let (mut app, f) = fixture(Layer::Sa);
|
||||
let message = begin(&app).unwrap();
|
||||
match change {
|
||||
0 => {
|
||||
app.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.remove(&Target::ProcessLink(f.link));
|
||||
}
|
||||
1 => {
|
||||
app.canvas_document = Id::new_v4();
|
||||
}
|
||||
2 => {
|
||||
app.canvas_epoch = app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
_ => {
|
||||
app.document
|
||||
.execute(Command::Rename(f.nodes[2], "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let source = app.document.bundle().clone();
|
||||
let selected = app.multiselect_snapshot();
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(message);
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot(), selected);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
let token = open(&mut app);
|
||||
app.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.remove(&Target::ProcessLink(f.link));
|
||||
let current = app.document.snapshot();
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert!(app.status.contains("expired"));
|
||||
assert!(std::sync::Arc::ptr_eq(¤t, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_pending_inputs_and_legacy_singleton_messages_preserve_all_members() {
|
||||
for pending in ["clean", "properties", "source", "tool", "busy", "form"] {
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = app.update(Message::Field("/name".into(), "Unapplied".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// pending", app.source_workspace.text());
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(std::sync::Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"tool" => app.behavior.topology.choose(CanvasTool::Connect),
|
||||
"busy" => app.busy = true,
|
||||
"form" => {
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
|
||||
context,
|
||||
tool: topology::Tool::Work,
|
||||
control: ControlKind::Choice,
|
||||
insertion: topology::Insertion::default(),
|
||||
}));
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let baseline = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
let source = app.source_workspace.bundle();
|
||||
if pending != "clean" {
|
||||
let message = begin(&app).unwrap();
|
||||
let _ = app.update(message);
|
||||
}
|
||||
let _ = app.update(Message::Delete);
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Remove(
|
||||
context, f.nodes[0],
|
||||
)));
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_qualified_member_refuses_whole_set_without_path_stripping() {
|
||||
let mut app = native();
|
||||
let owner = alias(app.project(), "caller");
|
||||
let root = alias(app.project(), "r1");
|
||||
let other = alias(app.project(), "r2");
|
||||
let leaf = alias(app.project(), "a");
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Expand(context, root)));
|
||||
let qualified = Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![root],
|
||||
occurrence: leaf,
|
||||
});
|
||||
choose(&mut app, qualified.clone(), multiselect::Choice::Replace);
|
||||
choose(&mut app, local(other), multiselect::Choice::Add);
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selected.targets(), &[qualified, local(other)]);
|
||||
let before = app.document.snapshot();
|
||||
let epoch = app.canvas_epoch;
|
||||
let message = begin(&app).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_unselected_context_subject_does_not_replace_the_group() {
|
||||
for unselected in [false, true] {
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let clicked = if unselected { f.nodes[2] } else { f.nodes[0] };
|
||||
let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN);
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(local(clicked)));
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
let message = begin(&app).unwrap();
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Invoke(
|
||||
token,
|
||||
Box::new(message),
|
||||
)));
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selection.targets());
|
||||
assert_eq!(review.snapshot.primary(), selection.primary());
|
||||
assert!(!review.plan.removed.contains(&f.nodes[2]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_review_lists_transitive_callers_even_when_all_modes_stay_explicit() {
|
||||
let mut app = native();
|
||||
let a = alias(app.project(), "a");
|
||||
let link = alias(app.project(), "approved");
|
||||
choose(&mut app, local(a), multiselect::Choice::Replace);
|
||||
choose(
|
||||
&mut app,
|
||||
Target::ProcessLink(link),
|
||||
multiselect::Choice::Add,
|
||||
);
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(
|
||||
review.plan.interpretation.is_empty(),
|
||||
"remaining Control keeps the definition explicit"
|
||||
);
|
||||
let rows = view::matching_rows(app.project(), review);
|
||||
for alias in ["@caller", "@outer"] {
|
||||
assert!(
|
||||
rows.iter().any(|row| row.label.contains(alias)
|
||||
&& row.disposition.contains("Uses changed definition"))
|
||||
);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
Event::Query {
|
||||
token,
|
||||
query: "@outer".into(),
|
||||
},
|
||||
);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.process
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let rows = view::matching_rows(app.project(), review);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(rows[0].label.contains("@assessment"));
|
||||
assert!(rows[0].label.contains("03 Mission using Response"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_group_delete_and_shift_delete_capture_group_escape_is_stamped() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{
|
||||
keyboard::{self, key::Named},
|
||||
mouse,
|
||||
};
|
||||
for shift in [false, true] {
|
||||
let (mut app, _) = fixture(Layer::Oa);
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let source = app.document.bundle().clone();
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let mut remove = key(Named::Delete);
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut remove {
|
||||
*modifiers = if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::default()
|
||||
};
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut app, &[remove.clone()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(output.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{output:?}");
|
||||
let token = app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove {
|
||||
*repeat = true;
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut app, &[remove], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!output
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ConfirmDelete(_)))
|
||||
);
|
||||
assert_eq!(app.delete_target.as_ref().unwrap().snapshot.token, token);
|
||||
let output = mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(output.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Cancel { token: captured } if *captured == token))), "{output:?}");
|
||||
assert!(app.delete_target.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_group_removal_queued_delete_is_revoked_by_later_press_in_same_batch() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard::key::Named, mouse};
|
||||
let (mut app, f) = fixture(Layer::Oa);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let local = app
|
||||
.behavior
|
||||
.topology
|
||||
.removal_test_point(&app, f.owner, f.nodes[0], None);
|
||||
let at = bounds.position() + iced::Vector::new(local.x, local.y);
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
key(Named::Delete),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: at }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { .. }))), "the first event queues a real review packet: {messages:?}");
|
||||
assert!(
|
||||
app.delete_target.is_none(),
|
||||
"later press must revoke that packet before App consumes it"
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"refusal does not retire the active gesture"
|
||||
);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.process_removal_enabled());
|
||||
assert!(
|
||||
begin(&app).is_none(),
|
||||
"active gesture cannot capture another review"
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(Named::Escape)],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
// Escape leaves viewport focus; F6 restores keyboard ownership and sends
|
||||
// the canvas cancellation boundary before a fresh Delete.
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(app.process_removal_enabled());
|
||||
// Cancelling the pointer gesture retains the group and permits a fresh review.
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Delete)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(app.delete_target.is_some());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_group_removal_queued_delete_is_revoked_by_camera_pan_start() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard::key::Named, mouse};
|
||||
for button in [mouse::Button::Middle, mouse::Button::Left] {
|
||||
let (mut app, _) = fixture(Layer::Sa);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let at = iced::Point::new(
|
||||
bounds.x + bounds.width - 12.,
|
||||
bounds.y + bounds.height - 12.,
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
key(Named::Delete),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: at }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(button)),
|
||||
],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::ProcessRemoval(event) if matches!(&**event, Event::Begin { .. }))), "real Delete is queued: {messages:?}");
|
||||
assert!(
|
||||
app.delete_target.is_none(),
|
||||
"camera input retires earlier review packet"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//! Review the complete captured removal impact before one explicit confirmation.
|
||||
use super::{Event, Review};
|
||||
use crate::app::{App, Message, diagram_removal};
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::style;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length::Fill};
|
||||
use oa_core::Project;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
pub(super) fn matching_rows(
|
||||
project: &Project,
|
||||
review: &Review,
|
||||
) -> Vec<super::super::removal_review::ImpactRow> {
|
||||
let selected = review
|
||||
.snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.filter_map(|target| match target {
|
||||
Target::ProcessOccurrence(endpoint) if endpoint.references.is_empty() => {
|
||||
Some(endpoint.occurrence)
|
||||
}
|
||||
Target::ProcessLink(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
super::super::removal_review::matching_rows(
|
||||
project,
|
||||
&review.plan.removed,
|
||||
&review.plan.changed,
|
||||
&selected,
|
||||
&review.plan.interpretation,
|
||||
&review.plan.affected_uses,
|
||||
&review.query,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn view<'a>(
|
||||
app: &'a App,
|
||||
target: &'a diagram_removal::Confirmation,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(review) = target.process.as_ref() else {
|
||||
return text("Process removal review is unavailable.").into();
|
||||
};
|
||||
let token = target.snapshot.token;
|
||||
let valid = app.process_removal_review_valid(target);
|
||||
let rows = matching_rows(app.project(), review);
|
||||
let callers: BTreeSet<_> = review
|
||||
.plan
|
||||
.affected_uses
|
||||
.values()
|
||||
.flatten()
|
||||
.chain(
|
||||
review
|
||||
.plan
|
||||
.interpretation
|
||||
.iter()
|
||||
.flat_map(|impact| impact.callers.iter()),
|
||||
)
|
||||
.copied()
|
||||
.collect();
|
||||
let semantic_summary = if review.plan.interpretation.is_empty() && callers.is_empty() {
|
||||
"No listed-order or analysis-mode changes; no affected caller definitions.".to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"Order / analysis changes in {} definitions · {} caller definitions affected. Review their effects below.",
|
||||
review.plan.interpretation.len(),
|
||||
callers.len()
|
||||
)
|
||||
};
|
||||
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
|
||||
let page = review.page.min(pages - 1);
|
||||
let mut impacts = column![].spacing(12).width(Fill);
|
||||
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
impacts = impacts.push(
|
||||
column![
|
||||
text(impact.disposition.clone())
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
text(impact.label.clone())
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
impacts = impacts.push(text("No matching effects. Try a name, alias or effect.").size(13));
|
||||
}
|
||||
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
|
||||
Message::ProcessRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page - 1,
|
||||
}))
|
||||
}));
|
||||
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::ProcessRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page + 1,
|
||||
}))
|
||||
}));
|
||||
let mut footer = column![].spacing(8).width(Fill);
|
||||
if !valid {
|
||||
footer = footer.push(
|
||||
text("This review expired. Cancel and review the current selection again.")
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
}
|
||||
footer = footer.push(
|
||||
row![
|
||||
container(
|
||||
button("Cancel")
|
||||
.padding(10)
|
||||
.on_press(Message::ProcessRemoval(Box::new(Event::Cancel { token })))
|
||||
)
|
||||
.id("delete-cancel"),
|
||||
container(
|
||||
button("Remove selected contributions / links")
|
||||
.style(button::danger)
|
||||
.padding(10)
|
||||
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
|
||||
)
|
||||
.id("delete-confirm"),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
text(format!("Remove {} selected contributions / links?", review.snapshot.targets().len())).size(21),
|
||||
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
|
||||
text(semantic_summary).size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
text_input("Search affected names, aliases or effects", &review.query)
|
||||
.id("process-removal-query")
|
||||
.on_input(move |query| Message::ProcessRemoval(Box::new(Event::Query { token, query })))
|
||||
.size(13),
|
||||
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
|
||||
.spacing(10).align_y(iced::Alignment::Center),
|
||||
scrollable(column![
|
||||
text("Removes selected local contributions or maintained links and the additional records listed below. Referenced definitions, work and exchanges are preserved. Reused topology, entry/exit connections and diagnostics may change. Review every listed order and affected caller definition; this review does not compute complete caller graphs. One Undo restores the entire change.")
|
||||
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
impacts,
|
||||
].spacing(14).width(Fill))
|
||||
.width(Fill).height(Fill).spacing(8).id("process-removal-impact"),
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
.width(Fill)
|
||||
// Keep confirmation and search outside the flexible, scrollable impact body.
|
||||
.height((app.window_size.height - 160.).clamp(260., 570.))
|
||||
.into()
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Shared stored-record and derived Process effect descriptions.
|
||||
use oa_core::{Id, Project};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
pub(crate) struct ImpactRow {
|
||||
pub(crate) label: String,
|
||||
pub(crate) disposition: String,
|
||||
}
|
||||
|
||||
pub(crate) fn matching_rows(
|
||||
project: &Project,
|
||||
removed: &BTreeSet<Id>,
|
||||
changed: &BTreeSet<Id>,
|
||||
selected: &BTreeSet<Id>,
|
||||
interpretation: &[crate::removal_effects::InterpretationChange],
|
||||
affected_uses: &BTreeMap<Id, BTreeSet<Id>>,
|
||||
query: &str,
|
||||
) -> Vec<ImpactRow> {
|
||||
let aliases: BTreeMap<Id, &str> = project.identities().into_iter().collect();
|
||||
let label = |id: Id| {
|
||||
let name = project.named(id);
|
||||
let identity = aliases
|
||||
.get(&id)
|
||||
.map_or_else(|| id.to_string(), |alias| format!("@{alias}"));
|
||||
if name == "Missing object" || name.is_empty() {
|
||||
identity
|
||||
} else {
|
||||
format!("{name} ({identity})")
|
||||
}
|
||||
};
|
||||
let mut rows = vec![];
|
||||
for id in removed {
|
||||
rows.push(ImpactRow {
|
||||
label: label(*id),
|
||||
disposition: if selected.contains(id) {
|
||||
"Removed · selected"
|
||||
} else {
|
||||
"Additional removed"
|
||||
}
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
for id in changed {
|
||||
rows.push(ImpactRow {
|
||||
label: label(*id),
|
||||
disposition: "Updated · preserved".into(),
|
||||
});
|
||||
}
|
||||
// Interpretation is separate from stored record deltas: callers may keep
|
||||
// identical records while their reused definition or analysis changes.
|
||||
for impact in interpretation {
|
||||
let owner = label(impact.owner);
|
||||
let shared_order = impact.before.local_order == impact.before.expanded_order
|
||||
&& impact.after.local_order == impact.after.expanded_order;
|
||||
for (dimension, before, after, shown) in [
|
||||
(
|
||||
if shared_order {
|
||||
"Listed and reused order"
|
||||
} else {
|
||||
"Local listed order"
|
||||
},
|
||||
&impact.before.local_order,
|
||||
&impact.after.local_order,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"Order inside reused definitions",
|
||||
&impact.before.expanded_order,
|
||||
&impact.after.expanded_order,
|
||||
!shared_order,
|
||||
),
|
||||
] {
|
||||
if !shown || before == after {
|
||||
continue;
|
||||
}
|
||||
for (when, order) in [("Before", before), ("After", after)] {
|
||||
let summary = match order {
|
||||
None => "Explicit topology; no implicit listed order".to_owned(),
|
||||
Some(ids) if ids.is_empty() => {
|
||||
"Empty order; no ordered pair; contextual flow may be incomplete".to_owned()
|
||||
}
|
||||
Some(ids) if ids.len() == 1 => {
|
||||
"One item in this order; no ordered pair".to_owned()
|
||||
}
|
||||
Some(ids) => format!(
|
||||
"Implicit listed order; {} adjacent ordered pairs",
|
||||
ids.len() - 1
|
||||
),
|
||||
};
|
||||
rows.push(ImpactRow {
|
||||
label: format!("{owner}: {summary}"),
|
||||
disposition: format!("{when} · {dimension}"),
|
||||
});
|
||||
if let Some(ids) = order {
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
rows.push(ImpactRow {
|
||||
label: format!("{owner}: #{} {}", index + 1, label(*id)),
|
||||
disposition: format!("{when} · {dimension} · listed position"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if impact.before.contextual_analysis != impact.after.contextual_analysis {
|
||||
rows.push(ImpactRow {
|
||||
label: format!(
|
||||
"{owner}: contextual analysis {} → {}",
|
||||
if impact.before.contextual_analysis {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
},
|
||||
if impact.after.contextual_analysis {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
),
|
||||
disposition:
|
||||
"Analysis mode changes; this is not a claim of complete graph connectivity"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut affected = affected_uses.clone();
|
||||
for impact in interpretation {
|
||||
affected
|
||||
.entry(impact.owner)
|
||||
.or_default()
|
||||
.extend(impact.callers.iter().copied());
|
||||
}
|
||||
for (definition, callers) in affected {
|
||||
for caller in callers {
|
||||
rows.push(ImpactRow {
|
||||
label: format!("{} uses {}", label(caller), label(definition)),
|
||||
disposition: "Uses changed definition · affected caller definition".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let query = query.trim().to_lowercase();
|
||||
rows.into_iter()
|
||||
.filter(|row| {
|
||||
query.is_empty()
|
||||
|| row.label.to_lowercase().contains(&query)
|
||||
|| row.disposition.to_lowercase().contains(&query)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
//! Explicit saved-view model deletion: one captured group and one transaction.
|
||||
use super::*;
|
||||
use crate::diagram::group_removal;
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::{model_menu, route_edit::Context};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Begin {
|
||||
input_generation: u64,
|
||||
context: Context,
|
||||
snapshot: Box<Snapshot>,
|
||||
},
|
||||
Query {
|
||||
token: u64,
|
||||
query: String,
|
||||
},
|
||||
Page {
|
||||
token: u64,
|
||||
page: usize,
|
||||
},
|
||||
Cancel {
|
||||
token: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct Review {
|
||||
input_generation: u64,
|
||||
pub(crate) context: Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) plan: group_removal::Plan,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
}
|
||||
|
||||
fn context(app: &App) -> Option<Context> {
|
||||
let current = match app.page {
|
||||
Page::Diagram => app.route_context()?,
|
||||
Page::Behavior if app.topology_context().is_none() && app.scenario_context().is_none() => {
|
||||
app.state_context()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let saved = app.project().diagrams.get(¤t.diagram)?;
|
||||
(saved.projection.is_architecture() || saved.projection.state_machine().is_some())
|
||||
.then_some(current)
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
let input_generation = app.saved_removal_input.idle()?;
|
||||
let context = context(app)?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
(snapshot.targets().len() > 1).then_some(Message::SavedRemoval(Box::new(Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot: Box::new(snapshot),
|
||||
})))
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn saved_removal_selecting(&self) -> bool {
|
||||
self.connection.is_none()
|
||||
&& self.selected_bend.is_none()
|
||||
&& match self.page {
|
||||
Page::Diagram => self
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_none_or(|state| !state.inline() && !state.saved.placing),
|
||||
Page::Behavior => {
|
||||
self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn saved_removal_enabled(&self) -> bool {
|
||||
diagram_removal::enabled(self)
|
||||
&& !self.source_rename.open()
|
||||
&& self.saved_removal_selecting()
|
||||
&& self.saved_removal_input.idle().is_some()
|
||||
&& context(self).is_some()
|
||||
}
|
||||
|
||||
fn saved_removal_matches(&self, captured: Context, snapshot: &Snapshot) -> bool {
|
||||
context(self) == Some(captured)
|
||||
&& self.diagram_selection.as_ref().is_some_and(|selection| {
|
||||
selection.matches(snapshot, multiselect::context(captured))
|
||||
})
|
||||
&& snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| self.multiselect_target_visible(target))
|
||||
}
|
||||
|
||||
pub(super) fn saved_removal_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Begin {
|
||||
input_generation,
|
||||
context: captured,
|
||||
snapshot,
|
||||
} => {
|
||||
// Refuse before source/editor/selection boundaries can disturb a
|
||||
// newer interaction, including a later press in the same batch.
|
||||
if self.saved_removal_input.idle() != Some(input_generation)
|
||||
|| !self.saved_removal_matches(captured, &snapshot)
|
||||
|| !self.saved_removal_enabled()
|
||||
|| snapshot.targets().len() < 2
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let plan = match group_removal::prepare(
|
||||
self.project(),
|
||||
captured.diagram,
|
||||
snapshot.targets(),
|
||||
) {
|
||||
Ok(plan) => plan,
|
||||
Err(reason) => {
|
||||
self.status = reason;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let Some(primary_target) = snapshot.primary().cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let (primary, placement) = match &primary_target {
|
||||
Target::Placement(id) => (
|
||||
self.project().diagrams[&captured.diagram].nodes[id].element,
|
||||
Some(*id),
|
||||
),
|
||||
Target::Port { port, .. } => (*port, None),
|
||||
Target::Edge(id) => (*id, None),
|
||||
Target::StateRow { relation, .. } => (*relation, None),
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let Some(token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.source_interaction();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = context(self).expect("admitted saved view");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.selection_target = Some(Box::new(primary_target));
|
||||
self.menu_sequence = token;
|
||||
self.delete_target = model_menu::Opened::new(self, request, token).map(|opened| {
|
||||
diagram_removal::Confirmation {
|
||||
snapshot: opened,
|
||||
selection: self.selected,
|
||||
placement,
|
||||
behavior_owner: None,
|
||||
scenario: None,
|
||||
process: None,
|
||||
saved: Some(Review {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot,
|
||||
plan,
|
||||
query: String::new(),
|
||||
page: 0,
|
||||
}),
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
Event::Query { token, query } => {
|
||||
if let Some(review) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
.and_then(|target| target.saved.as_mut())
|
||||
{
|
||||
review.query = query;
|
||||
review.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page { token, page } => {
|
||||
if let Some(review) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
.and_then(|target| target.saved.as_mut())
|
||||
{
|
||||
review.page = page;
|
||||
}
|
||||
}
|
||||
Event::Cancel { token } => {
|
||||
if self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.snapshot.token == token && target.saved.is_some())
|
||||
{
|
||||
self.delete_target = None;
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Model deletion cancelled. Model and selection unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn saved_removal_review_valid(
|
||||
&self,
|
||||
target: &diagram_removal::Confirmation,
|
||||
) -> bool {
|
||||
let Some(review) = &target.saved else {
|
||||
return false;
|
||||
};
|
||||
self.delete_pending
|
||||
&& !self.busy
|
||||
&& target.snapshot.valid(self)
|
||||
&& self.saved_removal_input.idle() == Some(review.input_generation)
|
||||
&& self.saved_removal_matches(review.context, &review.snapshot)
|
||||
&& self.selected == target.selection
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_rename.open()
|
||||
&& self.saved_removal_selecting()
|
||||
}
|
||||
|
||||
pub(super) fn confirm_saved_removal(&mut self, token: u64) {
|
||||
let Some(target) = self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| target.snapshot.token == token && target.saved.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let valid = self.saved_removal_review_valid(target);
|
||||
let target = self.delete_target.take().expect("captured saved review");
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if !valid {
|
||||
self.status = "Model deletion review expired. Nothing was deleted; review the current selection again.".into();
|
||||
return;
|
||||
}
|
||||
let review = target.saved.expect("saved review");
|
||||
let machine = self.project().diagrams[&review.context.diagram]
|
||||
.projection
|
||||
.state_machine();
|
||||
match self.document.execute(review.plan.command) {
|
||||
Ok(_) => {
|
||||
self.selected_node = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
// A selected machine can own the current saved view. Retire
|
||||
// those UI references only after its transaction succeeds.
|
||||
if self
|
||||
.behavior
|
||||
.active_owner
|
||||
.is_some_and(|id| !self.project().contains(id))
|
||||
{
|
||||
self.behavior.active_owner = None;
|
||||
}
|
||||
if self
|
||||
.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.is_some_and(|id| !self.project().diagrams.contains_key(&id))
|
||||
{
|
||||
self.behavior.diagram = Default::default();
|
||||
}
|
||||
if self.page == Page::Behavior
|
||||
&& let Some(machine) = machine.filter(|id| self.project().contains(*id))
|
||||
{
|
||||
self.selected = machine;
|
||||
self.behavior.active_owner = Some(machine);
|
||||
} else {
|
||||
self.selected = if self
|
||||
.project()
|
||||
.diagrams
|
||||
.contains_key(&review.context.diagram)
|
||||
{
|
||||
review.context.diagram
|
||||
} else {
|
||||
self.project().id
|
||||
};
|
||||
}
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.status = format!(
|
||||
"Deleted {} selected model subjects in one change across all views. Undo restores the model.",
|
||||
review.plan.selected.len()
|
||||
);
|
||||
}
|
||||
Err(error) => self.status = format!("Nothing deleted: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,940 +0,0 @@
|
||||
//! Saved semantic removal through App, with independently edited complete models.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::*;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn saved(project: &mut Project, layer: Layer, projection: ProjectionKind) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("saved_{}", id.simple()),
|
||||
name: "Saved view".into(),
|
||||
description: "Authored view metadata must survive".into(),
|
||||
layer,
|
||||
projection,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn architecture(project: Project, diagram: Id) -> App {
|
||||
validate_candidate(&project).unwrap();
|
||||
let layer = project.diagrams[&diagram].layer;
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
app
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, targets: &[Target]) {
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let _ = app.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
if index == 0 {
|
||||
multiselect::Choice::Replace
|
||||
} else {
|
||||
multiselect::Choice::Add
|
||||
},
|
||||
)));
|
||||
}
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), targets);
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::SavedRemoval(Box::new(event)));
|
||||
}
|
||||
|
||||
fn open(app: &mut App) -> u64 {
|
||||
assert!(app.saved_removal_enabled());
|
||||
let message = begin(app).expect("captured saved group");
|
||||
let _ = app.update(message);
|
||||
let confirmation = app.delete_target.as_ref().expect("saved group review");
|
||||
assert!(app.saved_removal_review_valid(confirmation));
|
||||
assert!(confirmation.saved.is_some());
|
||||
confirmation.snapshot.token
|
||||
}
|
||||
|
||||
fn source_matches(app: &App) {
|
||||
let compiled = source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
|
||||
fn mounted_fixture(state_view: bool) -> App {
|
||||
if state_view {
|
||||
let mut f = state_fixture(Layer::Sa);
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[Target::Placement(f.na), Target::Placement(f.nb)],
|
||||
);
|
||||
f.app
|
||||
} else {
|
||||
architecture_fixture(Layer::Sa).app
|
||||
}
|
||||
}
|
||||
|
||||
fn focus_canvas(mounted: &mut crate::behavior::canvas_workspace::tests::Mounted, app: &App) {
|
||||
use iced::advanced::widget::{self, operation::Focusable};
|
||||
struct Focus;
|
||||
impl widget::Operation for Focus {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
state: &mut dyn Focusable,
|
||||
) {
|
||||
if id == Some(&widget::Id::from("diagram-keyboard-owner")) {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
mounted.operate(app, &mut Focus);
|
||||
}
|
||||
|
||||
fn shift_delete() -> iced::Event {
|
||||
let mut event =
|
||||
crate::behavior::canvas_workspace::tests::key(iced::keyboard::key::Named::Delete);
|
||||
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { modifiers, .. }) = &mut event {
|
||||
*modifiers = iced::keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
event
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_removal_mounted_shift_delete_review_query_and_stamped_escape_preserve_source() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{keyboard::key::Named, mouse};
|
||||
for state_view in [false, true] {
|
||||
let mut app = mounted_fixture(state_view);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
focus_canvas(&mut mounted, &app);
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[shift_delete()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets() == selected.targets()))), "{state_view}: {messages:?}");
|
||||
let confirmation = app.delete_target.as_ref().unwrap();
|
||||
assert!(app.saved_removal_review_valid(confirmation));
|
||||
let token = confirmation.snapshot.token;
|
||||
send(
|
||||
&mut app,
|
||||
Event::Query {
|
||||
token,
|
||||
query: "no matching record".into(),
|
||||
},
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.saved_removal_review_valid(app.delete_target.as_ref().unwrap()));
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Cancel { token: t } if *t == token))), "{messages:?}");
|
||||
assert!(app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_removal_mounted_later_press_or_tab_revokes_queued_review_before_app() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard::key::Named, mouse};
|
||||
for state_view in [false, true] {
|
||||
for tab in [false, true] {
|
||||
let mut app = mounted_fixture(state_view);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
focus_canvas(&mut mounted, &app);
|
||||
let geometry = mounted.geometry(&app);
|
||||
let bounds = geometry
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.unwrap()
|
||||
.1;
|
||||
let at = iced::Point::new(
|
||||
bounds.x + bounds.width - 12.,
|
||||
bounds.y + bounds.height - 12.,
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let boundary = if tab {
|
||||
key(Named::Tab)
|
||||
} else {
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle))
|
||||
};
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
shift_delete(),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: at }),
|
||||
boundary,
|
||||
],
|
||||
mouse::Cursor::Available(at),
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::SavedRemoval(event) if matches!(&**event, Event::Begin { .. }))), "{state_view}/{tab}: {messages:?}");
|
||||
assert!(
|
||||
app.delete_target.is_none(),
|
||||
"later input must retire the queued request"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
if !tab {
|
||||
assert!(begin(&app).is_none(), "active pan owns an input lease");
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[Input::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
app.saved_removal_input.idle().is_some(),
|
||||
"abandoned gesture releases its lease"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Architecture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
parent: Id,
|
||||
child: Id,
|
||||
survivor: Id,
|
||||
parent_node: Id,
|
||||
child_node: Id,
|
||||
survivor_node: Id,
|
||||
edge: Id,
|
||||
port: Option<Id>,
|
||||
expected: Project,
|
||||
}
|
||||
|
||||
fn architecture_fixture(layer: Layer) -> Architecture {
|
||||
let mut project = Project::empty();
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let parent = project.add_element(kind, layer, "Parent", None).unwrap();
|
||||
let child = project
|
||||
.add_element(kind, layer, "Child", Some(parent))
|
||||
.unwrap();
|
||||
let survivor = project.add_element(kind, layer, "Survivor", None).unwrap();
|
||||
project.elements.get_mut(&survivor).unwrap().description =
|
||||
"Unrelated authored description".into();
|
||||
let edge = project.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Incident",
|
||||
child,
|
||||
survivor,
|
||||
);
|
||||
let port = match project.relations[&edge].body {
|
||||
RelationBody::FunctionalExchange { source_port, .. } => Some(source_port),
|
||||
_ => None,
|
||||
};
|
||||
let diagram = saved(&mut project, layer, ProjectionKind::Overview);
|
||||
let parent_node = project.place(diagram, parent, point(0., 0.));
|
||||
let child_node = project.place(diagram, child, point(40., 100.));
|
||||
let survivor_node = project.place(diagram, survivor, point(800., 50.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point(450.25, 90.5)],
|
||||
label: None,
|
||||
});
|
||||
let other = saved(&mut project, layer, ProjectionKind::Overview);
|
||||
let other_child = project.place(other, child, point(-40., 100.));
|
||||
project.place(other, survivor, point(700., 100.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&other)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
let mut expected = project.clone();
|
||||
expected.elements.remove(&parent);
|
||||
expected.elements.remove(&child);
|
||||
if let Some(port) = port {
|
||||
expected.elements.remove(&port);
|
||||
}
|
||||
expected.relations.remove(&edge);
|
||||
let view = expected.diagrams.get_mut(&diagram).unwrap();
|
||||
view.nodes.remove(&parent_node);
|
||||
view.nodes.remove(&child_node);
|
||||
view.routes.clear();
|
||||
let view = expected.diagrams.get_mut(&other).unwrap();
|
||||
view.nodes.remove(&other_child);
|
||||
view.view_options.hidden_edges.clear();
|
||||
validate_candidate(&expected).unwrap();
|
||||
let mut app = architecture(project, diagram);
|
||||
let mut targets = vec![
|
||||
Target::Placement(parent_node),
|
||||
Target::Placement(child_node),
|
||||
Target::Edge(edge),
|
||||
];
|
||||
if let Some(port) = port {
|
||||
targets.push(Target::Port {
|
||||
port,
|
||||
placement: child_node,
|
||||
});
|
||||
}
|
||||
choose(&mut app, &targets);
|
||||
Architecture {
|
||||
app,
|
||||
diagram,
|
||||
parent,
|
||||
child,
|
||||
survivor,
|
||||
parent_node,
|
||||
child_node,
|
||||
survivor_node,
|
||||
edge,
|
||||
port,
|
||||
expected,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_architecture_mixed_semantics_are_one_exact_model_source_transaction_in_oa_and_sa() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = architecture_fixture(layer);
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let camera = (f.app.zoom, f.app.pan);
|
||||
let token = open(&mut f.app);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let mut subjects: BTreeSet<_> = [f.parent, f.child, f.edge].into_iter().collect();
|
||||
subjects.extend(f.port);
|
||||
assert_eq!(review.plan.selected, subjects);
|
||||
assert_eq!(review.snapshot.targets(), selection.targets());
|
||||
assert_eq!(review.snapshot.primary(), selection.primary());
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &f.expected);
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_eq!((f.app.zoom, f.app.pan), camera);
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &f.expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
struct States {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
other: Id,
|
||||
machine: Id,
|
||||
region: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
na: Id,
|
||||
nb: Id,
|
||||
work: Id,
|
||||
action: Id,
|
||||
row: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn state_fixture(layer: Layer) -> States {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, layer, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, layer, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let a = project
|
||||
.add_element(Kind::State, layer, "Operating", Some(region))
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::State, layer, "Standby", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
"Reusable work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let action = project.relate(RelationKind::StateAction, "Entry work", a, work);
|
||||
let row = project.relate(RelationKind::StateTransition, "Internal check", a, a);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut project.relations.get_mut(&row).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
let edge = project.relate(RelationKind::StateTransition, "Change mode", a, b);
|
||||
let diagram = saved(
|
||||
&mut project,
|
||||
layer,
|
||||
ProjectionKind::StateMachine { machine },
|
||||
);
|
||||
let na = project.place(diagram, a, point(10., 20.));
|
||||
let nb = project.place(diagram, b, point(600., 20.));
|
||||
let other = saved(
|
||||
&mut project,
|
||||
layer,
|
||||
ProjectionKind::StateMachine { machine },
|
||||
);
|
||||
project.place(other, a, point(70., 80.));
|
||||
project.place(other, b, point(670., 80.));
|
||||
let architecture_view = saved(&mut project, layer, ProjectionKind::Overview);
|
||||
project.place(architecture_view, owner, point(0., 0.));
|
||||
validate_candidate(&project).unwrap();
|
||||
let mut app = architecture(project, architecture_view);
|
||||
app.page = Page::Behavior;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram = crate::behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
..Default::default()
|
||||
};
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
States {
|
||||
app,
|
||||
diagram,
|
||||
other,
|
||||
machine,
|
||||
region,
|
||||
a,
|
||||
b,
|
||||
na,
|
||||
nb,
|
||||
work,
|
||||
action,
|
||||
row,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_rows_delete_relations_not_referenced_work_and_state_overlap_is_exact() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for include_owner in [false, true] {
|
||||
let mut f = state_fixture(layer);
|
||||
let mut targets = vec![
|
||||
Target::StateRow {
|
||||
placement: f.na,
|
||||
relation: f.action,
|
||||
},
|
||||
Target::StateRow {
|
||||
placement: f.na,
|
||||
relation: f.row,
|
||||
},
|
||||
];
|
||||
if include_owner {
|
||||
targets.push(Target::Placement(f.na));
|
||||
} else {
|
||||
targets.push(Target::Placement(f.nb));
|
||||
}
|
||||
choose(&mut f.app, &targets);
|
||||
let before = f.app.document.snapshot();
|
||||
let camera = (f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan);
|
||||
let mut expected = f.app.project().clone();
|
||||
expected.relations.remove(&f.action);
|
||||
expected.relations.remove(&f.row);
|
||||
expected.relations.remove(&f.edge);
|
||||
let removed_state = if include_owner { f.a } else { f.b };
|
||||
expected.elements.remove(&removed_state);
|
||||
for view in expected.diagrams.values_mut() {
|
||||
view.nodes.retain(|_, node| node.element != removed_state);
|
||||
}
|
||||
validate_candidate(&expected).unwrap();
|
||||
let token = open(&mut f.app);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
review.context.diagram, f.diagram,
|
||||
"must not resolve stale Architecture field"
|
||||
);
|
||||
assert_eq!(
|
||||
review.plan.selected,
|
||||
[f.action, f.row, removed_state].into_iter().collect()
|
||||
);
|
||||
assert!(!review.plan.removed.contains(&f.work));
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(f.app.project().contains(f.work));
|
||||
assert_eq!(
|
||||
(f.app.behavior.diagram.zoom, f.app.behavior.diagram.pan),
|
||||
camera
|
||||
);
|
||||
assert!(f.app.project().contains(f.app.selected));
|
||||
source_matches(&f.app);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_cancel_and_stale_modal_packets_preserve_exact_redo_source_selection_and_camera() {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.survivor, "Future name".into()))
|
||||
.unwrap();
|
||||
let future = f.app.document.snapshot();
|
||||
assert!(f.app.document.undo());
|
||||
f.app.sync();
|
||||
f.app.reconcile_multiselect();
|
||||
let before = f.app.document.snapshot();
|
||||
let editor = f.app.source_editor.text();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let camera = (f.app.zoom, f.app.pan);
|
||||
let old_begin = begin(&f.app).unwrap();
|
||||
let first = open(&mut f.app);
|
||||
send(&mut f.app, Event::Cancel { token: first });
|
||||
let second = open(&mut f.app);
|
||||
let epoch = f.app.canvas_epoch;
|
||||
for message in [
|
||||
old_begin,
|
||||
Message::ConfirmDelete(first),
|
||||
Message::CancelDelete,
|
||||
Message::Escape,
|
||||
Message::SavedRemoval(Box::new(Event::Cancel { token: first })),
|
||||
Message::SavedRemoval(Box::new(Event::Query {
|
||||
token: first,
|
||||
query: "stale".into(),
|
||||
})),
|
||||
Message::SavedRemoval(Box::new(Event::Page {
|
||||
token: first,
|
||||
page: 999,
|
||||
})),
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, second);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert!(review.query.is_empty());
|
||||
assert_eq!(review.page, 0);
|
||||
}
|
||||
send(&mut f.app, Event::Cancel { token: second });
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.source_editor.text(), editor);
|
||||
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert_eq!((f.app.zoom, f.app.pan), camera);
|
||||
assert!(f.app.document.can_redo() && !f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &future.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &future.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_begin_and_confirm_reject_selection_membership_and_primary_aba() {
|
||||
for after_open in [false, true] {
|
||||
for primary_only in [false, true] {
|
||||
let mut f = architecture_fixture(Layer::Oa);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
let token = after_open.then(|| open(&mut f.app));
|
||||
let before = f.app.document.snapshot();
|
||||
let captured = f.app.multiselect_snapshot().unwrap();
|
||||
let selected = f.app.diagram_selection.as_mut().unwrap();
|
||||
let primary = captured.primary().unwrap();
|
||||
if primary_only {
|
||||
assert!(selected.set_primary(&Target::Placement(f.parent_node)));
|
||||
assert!(selected.set_primary(primary));
|
||||
} else {
|
||||
assert!(selected.remove(primary));
|
||||
assert!(selected.add(primary.clone(), |_| true));
|
||||
}
|
||||
let restored = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(restored.targets(), captured.targets());
|
||||
assert_eq!(restored.primary(), captured.primary());
|
||||
assert_ne!(restored.generation(), captured.generation());
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f
|
||||
.app
|
||||
.update(token.map(Message::ConfirmDelete).unwrap_or(delayed));
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
restored.targets()
|
||||
);
|
||||
if !after_open {
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_stale_context_packets_do_not_retire_current_selection_or_history() {
|
||||
for change in 0..4 {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
match change {
|
||||
0 => {
|
||||
f.app.canvas_document = Id::new_v4();
|
||||
}
|
||||
1 => {
|
||||
f.app.canvas_epoch = f.app.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
2 => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.survivor, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
_ => {
|
||||
f.app.active_diagram = f
|
||||
.app
|
||||
.project()
|
||||
.diagrams
|
||||
.keys()
|
||||
.copied()
|
||||
.find(|id| *id != f.diagram);
|
||||
}
|
||||
}
|
||||
let current = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f.app.update(delayed);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(¤t, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_admission_preserves_property_source_and_state_editor_drafts_and_nonselect_tool() {
|
||||
for pending in ["properties", "source", "form", "tool", "busy"] {
|
||||
let mut f = state_fixture(Layer::Sa);
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[Target::Placement(f.na), Target::Placement(f.nb)],
|
||||
);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
match pending {
|
||||
"properties" => {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/name".into(), "Unapplied".into()));
|
||||
}
|
||||
"source" => {
|
||||
let text = format!("{}\n// pending", f.app.source_workspace.text());
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
}
|
||||
"form" => {
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::States(
|
||||
behavior::states::Event::Edit(f.edge),
|
||||
)));
|
||||
let _ = f.app.update(Message::Behavior(behavior::Event::States(
|
||||
behavior::states::Event::Guard("Retained guard".into()),
|
||||
)));
|
||||
}
|
||||
"tool" => {
|
||||
f.app.behavior.diagram.tool = behavior::state_edit::Tool::Transition;
|
||||
}
|
||||
_ => f.app.busy = true,
|
||||
}
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = f.app.behavior.recovery_snapshot();
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f.app.update(delayed);
|
||||
assert!(f.app.delete_target.is_none(), "{pending}");
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &f.app.document.snapshot()),
|
||||
"{pending}"
|
||||
);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_input_lease_and_finished_gesture_reject_old_packets_at_begin_and_confirm() {
|
||||
for after_open in [false, true] {
|
||||
for finished in [false, true] {
|
||||
let mut f = architecture_fixture(Layer::Oa);
|
||||
let delayed = begin(&f.app).unwrap();
|
||||
let token = after_open.then(|| open(&mut f.app));
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let lease = f.app.saved_removal_input.acquire().unwrap();
|
||||
let mut lease = Some(lease);
|
||||
if finished {
|
||||
drop(lease.take());
|
||||
}
|
||||
let _ = f
|
||||
.app
|
||||
.update(token.map(Message::ConfirmDelete).unwrap_or(delayed));
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
drop(lease);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_context_menu_keeps_the_whole_group_even_when_unselected_object_was_clicked() {
|
||||
for unselected in [false, true] {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let (object, placement) = if unselected {
|
||||
(f.survivor, f.survivor_node)
|
||||
} else {
|
||||
(f.child, f.child_node)
|
||||
};
|
||||
let mut request = model_menu::Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = Some(placement);
|
||||
request.selection_target = Some(Box::new(Target::Placement(placement)));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let entries = model_menu::popup(&f.app).unwrap().entries;
|
||||
let invoke = entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
crate::context_menu::Entry::Action { label, message, .. }
|
||||
if label == "Delete selected from model…" =>
|
||||
{
|
||||
message
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("explicit semantic group menu action");
|
||||
let _ = f.app.update(invoke);
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.saved
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selected.targets());
|
||||
assert!(!review.plan.selected.contains(&f.survivor));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_hide_and_plain_delete_never_promote_mixed_targets_to_model_removal() {
|
||||
for message_kind in ["delete", "hide"] {
|
||||
let mut f = architecture_fixture(Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let _ = f.app.update(if message_kind == "delete" {
|
||||
Message::Delete
|
||||
} else {
|
||||
Message::HideSelected
|
||||
});
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
let token = open(&mut f.app);
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &f.expected);
|
||||
}
|
||||
let mut f = architecture_fixture(Layer::Oa);
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[
|
||||
Target::Placement(f.parent_node),
|
||||
Target::Placement(f.survivor_node),
|
||||
],
|
||||
);
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.parent_node);
|
||||
view.nodes.remove(&f.survivor_node);
|
||||
let _ = f.app.update(Message::HideSelected);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(f.app.project().contains(f.parent) && f.app.project().contains(f.survivor));
|
||||
source_matches(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_state_owner_deletion_removes_owned_views_and_chooses_surviving_inspection_context() {
|
||||
let mut f = state_fixture(Layer::Sa);
|
||||
let mut project = f.app.project().clone();
|
||||
let owner_node = project.place(f.diagram, f.machine, point(-400., -200.));
|
||||
f.app.document = Document::from_project(project.clone(), LIMITS).unwrap();
|
||||
f.app.source_workspace = Workspace::new(f.app.document.bundle(), true);
|
||||
f.app.sync();
|
||||
f.app.reconcile_multiselect();
|
||||
choose(
|
||||
&mut f.app,
|
||||
&[Target::Placement(owner_node), Target::Placement(f.na)],
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = project;
|
||||
for id in [f.machine, f.region, f.a, f.b] {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
for id in [f.action, f.row, f.edge] {
|
||||
expected.relations.remove(&id);
|
||||
}
|
||||
expected.diagrams.remove(&f.diagram);
|
||||
expected.diagrams.remove(&f.other);
|
||||
validate_candidate(&expected).unwrap();
|
||||
let token = open(&mut f.app);
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(f.app.project().contains(f.app.selected));
|
||||
assert!(
|
||||
f.app
|
||||
.behavior
|
||||
.active_owner
|
||||
.is_none_or(|id| f.app.project().contains(id))
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.is_none_or(|id| f.app.project().diagrams.contains_key(&id))
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| s.targets().is_empty())
|
||||
);
|
||||
source_matches(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! Review the complete captured removal impact before one explicit confirmation.
|
||||
use super::{Event, Review};
|
||||
use crate::app::{App, Message, diagram_removal};
|
||||
use crate::style;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length::Fill};
|
||||
use oa_core::Project;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
pub(super) fn matching_rows(
|
||||
project: &Project,
|
||||
review: &Review,
|
||||
) -> Vec<super::super::removal_review::ImpactRow> {
|
||||
super::super::removal_review::matching_rows(
|
||||
project,
|
||||
&review.plan.removed,
|
||||
&review.plan.changed,
|
||||
&review.plan.selected,
|
||||
&review.plan.interpretation,
|
||||
&review.plan.affected_uses,
|
||||
&review.query,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn view<'a>(
|
||||
app: &'a App,
|
||||
target: &'a diagram_removal::Confirmation,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(review) = target.saved.as_ref() else {
|
||||
return text("Model deletion review is unavailable.").into();
|
||||
};
|
||||
let token = target.snapshot.token;
|
||||
let valid = app.saved_removal_review_valid(target);
|
||||
let rows = matching_rows(app.project(), review);
|
||||
let callers: BTreeSet<_> = review
|
||||
.plan
|
||||
.affected_uses
|
||||
.values()
|
||||
.flatten()
|
||||
.chain(
|
||||
review
|
||||
.plan
|
||||
.interpretation
|
||||
.iter()
|
||||
.flat_map(|impact| impact.callers.iter()),
|
||||
)
|
||||
.copied()
|
||||
.collect();
|
||||
let semantic_summary = if review.plan.interpretation.is_empty() && callers.is_empty() {
|
||||
"No detected Process order/analysis changes or affected Process caller definitions."
|
||||
.to_owned()
|
||||
} else {
|
||||
format!(
|
||||
"Order / analysis changes in {} definitions · {} caller definitions affected. Review their effects below.",
|
||||
review.plan.interpretation.len(),
|
||||
callers.len()
|
||||
)
|
||||
};
|
||||
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
|
||||
let page = review.page.min(pages - 1);
|
||||
let mut impacts = column![].spacing(12).width(Fill);
|
||||
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
impacts = impacts.push(
|
||||
column![
|
||||
text(impact.disposition.clone())
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
text(impact.label.clone())
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
impacts = impacts.push(text("No matching effects. Try a name, alias or effect.").size(13));
|
||||
}
|
||||
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
|
||||
Message::SavedRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page - 1,
|
||||
}))
|
||||
}));
|
||||
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::SavedRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page + 1,
|
||||
}))
|
||||
}));
|
||||
let mut footer = column![].spacing(8).width(Fill);
|
||||
if !valid {
|
||||
footer = footer.push(
|
||||
text("This review expired. Cancel and review the current selection again.")
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
}
|
||||
footer = footer.push(
|
||||
row![
|
||||
container(
|
||||
button("Cancel")
|
||||
.padding(10)
|
||||
.on_press(Message::SavedRemoval(Box::new(Event::Cancel { token })))
|
||||
)
|
||||
.id("delete-cancel"),
|
||||
container(
|
||||
button("Delete selected from model")
|
||||
.style(button::danger)
|
||||
.padding(10)
|
||||
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
|
||||
)
|
||||
.id("delete-confirm"),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
text(format!("Delete {} model subjects?", review.plan.selected.len())).size(21),
|
||||
text(format!("{} selected diagram objects · {} unique model subjects", review.snapshot.targets().len(), review.plan.selected.len())).size(13),
|
||||
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
|
||||
text(semantic_summary).size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
text_input("Search affected names, aliases or effects", &review.query)
|
||||
.id("saved-removal-query")
|
||||
.on_input(move |query| Message::SavedRemoval(Box::new(Event::Query { token, query })))
|
||||
.size(13),
|
||||
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
|
||||
.spacing(10).align_y(iced::Alignment::Center),
|
||||
scrollable(column![
|
||||
text("Deletes the selected model subjects and their listed dependent records across all views. This is different from hiding placements. Removing a State row preserves its referenced work; deleting a trace preserves its endpoints unless separately selected or owned by another deleted subject. Reused Process definitions may change; review their listed effects and callers. One Undo restores the entire model and source change.")
|
||||
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
impacts,
|
||||
].spacing(14).width(Fill))
|
||||
.width(Fill).height(Fill).spacing(8).id("saved-removal-impact"),
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
.width(Fill)
|
||||
// Keep confirmation and search outside the flexible, scrollable impact body.
|
||||
.height((app.window_size.height - 160.).clamp(260., 570.))
|
||||
.into()
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Exact rendered Scenario selection, independent of semantic movement permission.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{self, selection};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_selection_envelope(&self, event: &selection::Event) -> bool {
|
||||
self.scenario_context() == Some(event.context)
|
||||
&& self.diagram_selection.as_ref().is_some_and(|selected| {
|
||||
selected.matches(&event.snapshot, selection::context(event.context))
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reconcile_scenario_multiselect(&mut self) {
|
||||
let Some(context) = self.scenario_context() else {
|
||||
return;
|
||||
};
|
||||
let scope = selection::context(context).scope;
|
||||
let visible = selection::visible(self.project(), context, self.selected);
|
||||
let old = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == scope)
|
||||
.and_then(Selection::primary)
|
||||
.cloned();
|
||||
let legacy = Target::ScenarioObject(self.selected);
|
||||
let held = old.as_ref() == Some(&legacy);
|
||||
let lost = old.as_ref().is_some_and(|target| !visible.contains(target));
|
||||
let selected = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(scope));
|
||||
selected.reconcile(scope, |target| visible.contains(target));
|
||||
if held || lost {
|
||||
if selected.primary() != old.as_ref() {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
} else if selected.primary() != Some(&legacy) {
|
||||
if visible.contains(&legacy) {
|
||||
selected.replace(legacy, |target| visible.contains(target));
|
||||
} else {
|
||||
selected.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn inspect_scenario_multiselect_primary(&mut self) {
|
||||
let Some(context) = self.scenario_context() else {
|
||||
return;
|
||||
};
|
||||
self.selected = match self.diagram_selection.as_ref().and_then(Selection::primary) {
|
||||
Some(Target::ScenarioObject(id)) => *id,
|
||||
_ => context.scenario,
|
||||
};
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
/// This is dispatched before shared navigation/gesture retirement. Invalid
|
||||
/// captures and unchanged selection must not disturb a newer input owner.
|
||||
pub(super) fn scenario_selection_event(&mut self, event: selection::Event) -> Task<Message> {
|
||||
use multiselect::Choice;
|
||||
use selection::Intent;
|
||||
if !self.scenario_selection_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
let visible = selection::visible(self.project(), event.context, self.selected);
|
||||
if !event.snapshot.targets().iter().all(|t| visible.contains(t)) {
|
||||
return Task::none();
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.source_rename.open()
|
||||
|| self.behavior.scenario.canvas_tool(event.context.scenario)
|
||||
!= scenario::CanvasTool::Select
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the Scenario selection.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let selected = self.diagram_selection.as_mut().expect("admitted selection");
|
||||
let valid = |target: &Target| visible.contains(target);
|
||||
let changed = match &event.intent {
|
||||
Intent::Choose(target, choice) => {
|
||||
if !valid(target) {
|
||||
return Task::none();
|
||||
}
|
||||
match choice {
|
||||
Choice::Replace => selected.replace(target.clone(), valid),
|
||||
Choice::Add => selected.add(target.clone(), valid),
|
||||
Choice::Remove => selected.remove(target),
|
||||
Choice::Toggle => selected.toggle(target.clone(), valid),
|
||||
Choice::Inspect => selected.set_primary(target),
|
||||
}
|
||||
}
|
||||
Intent::Collapse(target) => {
|
||||
if !selected.contains(target) || !valid(target) {
|
||||
return Task::none();
|
||||
}
|
||||
selected.replace(target.clone(), valid)
|
||||
}
|
||||
Intent::Clear => selected.clear(),
|
||||
Intent::Refuse(reason) => {
|
||||
self.status = reason.clone();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.scenario_reorder_cancel();
|
||||
self.source_interaction();
|
||||
self.reconcile_pending_diagram_reveal(&Message::ScenarioCanvas(
|
||||
scenario::CanvasEvent::Selection(Box::new(event)),
|
||||
));
|
||||
self.behavior.scenario.cancel_spacing();
|
||||
// Every generation change, including a secondary membership ABA,
|
||||
// retires old singleton spacing packets as well as group captures.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn scenario_group_selected(&self) -> bool {
|
||||
self.scenario_context().is_some()
|
||||
&& self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
}
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection::Intent, spacing};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Surface;
|
||||
use iced::{Size, Vector};
|
||||
|
||||
fn setup(saved: bool) -> (App, Id, Id, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = spacing::controller::tests::fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
if saved {
|
||||
let _ = app.update(Message::Diagram(super::super::Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
} else {
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
(app, owner, diagram, lane, message)
|
||||
}
|
||||
|
||||
fn capture(app: &App, intent: Intent) -> selection::Event {
|
||||
selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent,
|
||||
}
|
||||
}
|
||||
fn send(app: &mut App, event: selection::Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
|
||||
send(
|
||||
app,
|
||||
capture(app, Intent::Choose(Target::ScenarioObject(id), choice)),
|
||||
);
|
||||
}
|
||||
fn inert(app: &mut App, messages: Vec<Message>, preserve_status: bool) {
|
||||
let model = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let source = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let history = (app.document.can_undo(), app.document.can_redo());
|
||||
let selected = app.multiselect_snapshot();
|
||||
let primary = app.selected;
|
||||
let context = app.scenario_context();
|
||||
let camera = app.camera_boundary();
|
||||
let status = app.status.clone();
|
||||
let spacing = (
|
||||
app.behavior.scenario.spacing_stamp(),
|
||||
app.behavior.scenario.spacing_token(),
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
|
||||
assert_eq!(app.multiselect_snapshot(), selected);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.scenario.spacing_stamp(),
|
||||
app.behavior.scenario.spacing_token()
|
||||
),
|
||||
spacing
|
||||
);
|
||||
if preserve_status {
|
||||
assert_eq!(app.status, status);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn packet(event: selection::Event) -> Message {
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_scenario_membership_primary_and_noops_preserve_model_source_and_history() {
|
||||
for saved in [false, true] {
|
||||
let (mut app, owner, diagram, lane, message) = setup(saved);
|
||||
let model = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
choose(&mut app, message, multiselect::Choice::Inspect);
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
snapshot.targets(),
|
||||
&[
|
||||
Target::ScenarioObject(message),
|
||||
Target::ScenarioObject(lane)
|
||||
]
|
||||
);
|
||||
assert_eq!(snapshot.primary(), Some(&Target::ScenarioObject(message)));
|
||||
assert_eq!(
|
||||
snapshot.context().scope.surface,
|
||||
if saved {
|
||||
Surface::Saved(diagram)
|
||||
} else {
|
||||
Surface::Scenario(owner)
|
||||
}
|
||||
);
|
||||
assert_eq!(app.selected, message);
|
||||
let repeated = capture(
|
||||
&app,
|
||||
Intent::Choose(Target::ScenarioObject(message), multiselect::Choice::Add),
|
||||
);
|
||||
inert(&mut app, vec![packet(repeated)], true);
|
||||
let stale_collapse = capture(&app, Intent::Collapse(Target::ScenarioObject(lane)));
|
||||
choose(&mut app, lane, multiselect::Choice::Remove);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
choose(&mut app, message, multiselect::Choice::Inspect);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
snapshot.primary()
|
||||
);
|
||||
inert(&mut app, vec![packet(stale_collapse)], true);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_owner_representation_round_trip_clears_group_and_rejects_old_packets() {
|
||||
let (mut app, owner, diagram, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let old = capture(&app, Intent::Clear);
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let mut second = app.project().diagrams[&diagram].clone();
|
||||
let other = Id::new_v4();
|
||||
second.id = other;
|
||||
second.alias = "other_saved".into();
|
||||
second.name = "Other saved".into();
|
||||
// Navigation itself is tested against a model with both saved identities.
|
||||
let mut project = app.project().clone();
|
||||
project.diagrams.insert(other, second);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(super::super::Choice {
|
||||
id: diagram,
|
||||
label: "A".into(),
|
||||
}));
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let first = capture(&app, Intent::Clear);
|
||||
let first_visit = first.context.visit;
|
||||
for representation in [
|
||||
Representation::Saved { diagram: other },
|
||||
Representation::Derived { owner },
|
||||
Representation::Saved { diagram },
|
||||
] {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
representation,
|
||||
)));
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().len() <= 1);
|
||||
}
|
||||
assert_ne!(app.scenario_context().unwrap().visit, first_visit);
|
||||
choose(&mut app, message, multiselect::Choice::Replace);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
inert(&mut app, vec![packet(first), packet(old)], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_targets_and_bare_generic_packets_are_inert_before_side_effects() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let current = app.multiselect_context().unwrap();
|
||||
let events = [
|
||||
Target::Whole,
|
||||
Target::Placement(message),
|
||||
Target::ScenarioObject(Id::new_v4()),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|target| {
|
||||
packet(capture(
|
||||
&app,
|
||||
Intent::Choose(target, multiselect::Choice::Replace),
|
||||
))
|
||||
})
|
||||
.chain([
|
||||
Message::DiagramSelection(multiselect::Event::Clear(current)),
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
current,
|
||||
Target::ScenarioObject(message),
|
||||
multiselect::Choice::Replace,
|
||||
)),
|
||||
])
|
||||
.collect();
|
||||
inert(&mut app, events, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_rejects_legacy_singleton_remove_reorder_and_index_arrows() {
|
||||
let (mut app, owner, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let context = app.scenario_context().unwrap();
|
||||
inert(
|
||||
&mut app,
|
||||
vec![
|
||||
Message::ScenarioCanvas(CanvasEvent::Select(context, message)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Remove(context, lane)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Reorder {
|
||||
context,
|
||||
occurrence: message,
|
||||
slot: scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
},
|
||||
}),
|
||||
Message::Command(Command::MoveStep(owner, 0, 1)),
|
||||
Message::Delete,
|
||||
],
|
||||
false,
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secondary_selection_aba_retires_old_spacing_without_canceling_a_new_gesture() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let old = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let group_begin = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(group_begin)));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert_eq!(
|
||||
app.canvas_epoch, epoch,
|
||||
"pending click retains its admitted lifetime"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.spacing_token(),
|
||||
Some(group_begin.token())
|
||||
);
|
||||
choose(&mut app, lane, multiselect::Choice::Remove);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
inert(
|
||||
&mut app,
|
||||
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
|
||||
true,
|
||||
);
|
||||
let current = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let spacing::Event::Begin { press_screen, .. } = current else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
inert(
|
||||
&mut app,
|
||||
vec![
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(old)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
})),
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: press_screen + Vector::new(0., 25.),
|
||||
})),
|
||||
],
|
||||
true,
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
spacing::Event::Finish {
|
||||
stamp: current.stamp(),
|
||||
token: current.token(),
|
||||
screen: press_screen + Vector::new(0., 25.),
|
||||
},
|
||||
)));
|
||||
assert!(app.document.can_undo());
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(message)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_singleton_selection_aba_retires_old_spacing_begin() {
|
||||
for canvas in [false, true] {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let old = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
for id in [lane, message] {
|
||||
let event = if canvas {
|
||||
Message::ScenarioCanvas(CanvasEvent::Select(app.scenario_context().unwrap(), id))
|
||||
} else {
|
||||
Message::Select(id)
|
||||
};
|
||||
let _ = app.update(event);
|
||||
assert_eq!(app.selected, id);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(id)]
|
||||
);
|
||||
}
|
||||
assert_ne!(app.scenario_context().unwrap(), old.stamp().context);
|
||||
inert(
|
||||
&mut app,
|
||||
vec![Message::ScenarioCanvas(CanvasEvent::Spacing(old))],
|
||||
true,
|
||||
);
|
||||
let current = super::super::scenario_spacing::tests::packet_for_app(
|
||||
&app,
|
||||
spacing::Target::Message(message),
|
||||
);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(current)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_primary_edit_is_not_applied_by_selection_input() {
|
||||
let (mut app, _, _, lane, message) = setup(true);
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending primary name".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let event = capture(
|
||||
&app,
|
||||
Intent::Choose(Target::ScenarioObject(lane), multiselect::Choice::Add),
|
||||
);
|
||||
inert(&mut app, vec![packet(event)], false);
|
||||
assert!(app.draft_dirty());
|
||||
assert_ne!(app.project().named(message), "Pending primary name");
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
//! One captured Scenario selection, one impact review, one document transaction.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{self, group_removal, selection};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod view;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Begin {
|
||||
context: scenario::Context,
|
||||
snapshot: Box<Snapshot>,
|
||||
},
|
||||
Query {
|
||||
token: u64,
|
||||
query: String,
|
||||
},
|
||||
Page {
|
||||
token: u64,
|
||||
page: usize,
|
||||
},
|
||||
Cancel {
|
||||
token: u64,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct Review {
|
||||
pub(crate) context: scenario::Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) plan: group_removal::Plan,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
let context = app.scenario_context()?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
(snapshot.targets().len() > 1).then_some(Message::ScenarioRemoval(Box::new(Event::Begin {
|
||||
context,
|
||||
snapshot: Box::new(snapshot),
|
||||
})))
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_removal_enabled(&self) -> bool {
|
||||
self.scenario_reorder_enabled() && self.scenario_reorder.session.is_none()
|
||||
}
|
||||
|
||||
fn scenario_removal_matches(&self, context: scenario::Context, snapshot: &Snapshot) -> bool {
|
||||
if self.scenario_context() != Some(context)
|
||||
|| !self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selected| selected.matches(snapshot, selection::context(context)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let visible = selection::visible(self.project(), context, self.selected);
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
}
|
||||
|
||||
pub(super) fn scenario_removal_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::Begin { context, snapshot } => {
|
||||
// Admission precedes every shared input/selection boundary. A
|
||||
// stale packet must not prune the set or retire a newer editor.
|
||||
if !self.scenario_removal_matches(context, &snapshot)
|
||||
|| !self.scenario_removal_enabled()
|
||||
|| snapshot.targets().len() < 2
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let plan = match group_removal::prepare(
|
||||
self.project(),
|
||||
context.scenario,
|
||||
snapshot.targets(),
|
||||
) {
|
||||
Ok(plan) => plan,
|
||||
Err(reason) => {
|
||||
self.status = reason;
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
|
||||
return Task::none();
|
||||
};
|
||||
let primary = *primary;
|
||||
self.scenario_reorder_cancel();
|
||||
self.source_interaction();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.scenario_context().expect("admitted Scenario");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
let mut request = model_menu::Request::object(primary, iced::Point::ORIGIN);
|
||||
request.scenario = Some(Box::new(context));
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(primary)));
|
||||
self.menu_sequence = self.menu_sequence.wrapping_add(1);
|
||||
self.delete_target = model_menu::Opened::new(self, request, self.menu_sequence)
|
||||
.map(|opened| diagram_removal::Confirmation {
|
||||
snapshot: opened,
|
||||
selection: self.selected,
|
||||
placement: None,
|
||||
behavior_owner: Some(context.scenario),
|
||||
process: None,
|
||||
saved: None,
|
||||
scenario: Some(Review {
|
||||
context,
|
||||
snapshot,
|
||||
plan,
|
||||
query: String::new(),
|
||||
page: 0,
|
||||
}),
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
Event::Query { token, query } => {
|
||||
if let Some(target) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
&& let Some(review) = target.scenario.as_mut()
|
||||
{
|
||||
review.query = query;
|
||||
review.page = 0;
|
||||
}
|
||||
}
|
||||
Event::Page { token, page } => {
|
||||
if let Some(target) = self
|
||||
.delete_target
|
||||
.as_mut()
|
||||
.filter(|target| target.snapshot.token == token)
|
||||
&& let Some(review) = target.scenario.as_mut()
|
||||
{
|
||||
review.page = page;
|
||||
}
|
||||
}
|
||||
Event::Cancel { token } => {
|
||||
if self.delete_target.as_ref().is_some_and(|target| {
|
||||
target.snapshot.token == token && target.scenario.is_some()
|
||||
}) {
|
||||
self.delete_target = None;
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Removal cancelled. Model and selection unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_removal_review_valid(
|
||||
&self,
|
||||
target: &diagram_removal::Confirmation,
|
||||
) -> bool {
|
||||
let Some(review) = &target.scenario else {
|
||||
return false;
|
||||
};
|
||||
self.delete_pending
|
||||
&& !self.busy
|
||||
&& target.snapshot.valid(self)
|
||||
&& self.scenario_removal_matches(review.context, &review.snapshot)
|
||||
&& self.selected == target.selection
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_rename.open()
|
||||
&& self.scenario_reorder.session.is_none()
|
||||
&& self.behavior.scenario.spacing_token().is_none()
|
||||
&& self.behavior.scenario.canvas_tool(review.context.scenario)
|
||||
== scenario::CanvasTool::Select
|
||||
}
|
||||
|
||||
pub(super) fn confirm_scenario_removal(&mut self, token: u64) {
|
||||
let Some(target) = self
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.filter(|target| target.snapshot.token == token && target.scenario.is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let valid = self.scenario_removal_review_valid(target);
|
||||
let target = self.delete_target.take().expect("captured review");
|
||||
self.delete_pending = false;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if !valid {
|
||||
self.status =
|
||||
"Removal review expired. Nothing was removed; review the current selection again."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
let review = target.scenario.expect("Scenario review");
|
||||
match self.document.execute(review.plan.command) {
|
||||
Ok(_) => {
|
||||
self.selected = review.context.scenario;
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(review.context.scenario);
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.status = format!(
|
||||
"Removed {} selected Scenario objects in one change. Referenced definitions preserved. Undo restores the model.",
|
||||
review.snapshot.targets().len()
|
||||
);
|
||||
}
|
||||
Err(error) => self.status = format!("Nothing removed: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,758 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, direct_tests};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
call: Id,
|
||||
reply: Id,
|
||||
tail: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn setup(saved: bool) -> Fixture {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let mut tail = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unselected tail",
|
||||
Some(f.owner),
|
||||
)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::Occurrence { body, .. }) = tail.shared_mut() {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
let tail_id = tail.id;
|
||||
project.elements.insert(tail_id, tail);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(tail_id);
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "removal_view".into(),
|
||||
name: "Removal view".into(),
|
||||
description: "Preserved authored view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
|
||||
},
|
||||
);
|
||||
let other = project
|
||||
.make_element(Kind::Scenario, Layer::Oa, "Unrelated Scenario", None)
|
||||
.unwrap();
|
||||
let other_owner = other.id;
|
||||
project.elements.insert(other_owner, other);
|
||||
let mut other_view = project.diagrams[&diagram].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "unrelated_removal_view".into();
|
||||
other_view.name = "Unrelated saved view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
project.diagrams.insert(other_view.id, other_view);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(if saved {
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
})
|
||||
} else {
|
||||
Message::Page(Page::Behavior)
|
||||
});
|
||||
// Deliberately reverse semantic order; the primary is call, not the last step.
|
||||
for (id, choice) in [
|
||||
(f.reply, multiselect::Choice::Replace),
|
||||
(f.call, multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.scenario_removal_enabled());
|
||||
Fixture {
|
||||
app,
|
||||
owner: f.owner,
|
||||
call: f.call,
|
||||
reply: f.reply,
|
||||
tail: tail_id,
|
||||
exchange: f.exchange,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioRemoval(Box::new(event)));
|
||||
}
|
||||
fn captured(app: &App) -> Event {
|
||||
Event::Begin {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: Box::new(app.multiselect_snapshot().unwrap()),
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> u64 {
|
||||
let event = captured(app);
|
||||
send(app, event);
|
||||
let target = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.expect("whole-selection review opened");
|
||||
assert!(app.delete_pending && target.scenario.is_some());
|
||||
assert!(
|
||||
target.snapshot.valid(app),
|
||||
"review captures refreshed modal epoch"
|
||||
);
|
||||
target.snapshot.token
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_is_one_exact_model_source_transaction_in_both_representations() {
|
||||
for saved in [false, true] {
|
||||
let Fixture {
|
||||
mut app,
|
||||
owner,
|
||||
call,
|
||||
reply,
|
||||
tail,
|
||||
exchange,
|
||||
} = setup(saved);
|
||||
let baseline = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let token = open(&mut app);
|
||||
let review = app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.scenario
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selected.targets());
|
||||
assert_eq!(review.snapshot.primary(), selected.primary());
|
||||
assert_eq!(review.plan.removed, [call, reply].into_iter().collect());
|
||||
assert!(review.plan.changed.contains(&owner));
|
||||
assert!(!review.plan.removed.contains(&exchange));
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
let mut expected = baseline.decoded.project.clone();
|
||||
expected.elements.remove(&call);
|
||||
expected.elements.remove(&reply);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![tail];
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().contains(exchange));
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
let removed = app.document.bundle().clone();
|
||||
assert_ne!(removed, baseline.bundle);
|
||||
let reopened = Document::from_bundle(removed.clone(), Default::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"one Undo restores the entire group"
|
||||
);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_source_write_failure_preserves_snapshot_selection_camera_and_redo() {
|
||||
use oa_core::source_project::{self, ProjectLimits};
|
||||
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
|
||||
.unwrap();
|
||||
let lanes = ["a", "b"].map(|alias| {
|
||||
let id = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, alias, Some(owner))
|
||||
.unwrap();
|
||||
project.elements.get_mut(&id).unwrap().alias = alias.into();
|
||||
id
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
for index in 0..256 {
|
||||
let mut message = project
|
||||
.make_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
&format!("Message {index}"),
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let SharedBody::Occurrence { body, .. } = message.shared_mut().unwrap() else {
|
||||
panic!("message occurrence");
|
||||
};
|
||||
*body = OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: oa_core::MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
};
|
||||
messages.push(message.id);
|
||||
project.elements.insert(message.id, message);
|
||||
}
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.clone();
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "removal_view".into(),
|
||||
name: "Removal view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
|
||||
},
|
||||
);
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
let bundle = source_project::format_bundle(&project);
|
||||
let bytes = |bundle: &source_project::SourceBundle| {
|
||||
bundle.files.values().map(String::len).sum::<usize>()
|
||||
};
|
||||
let targets = lanes.map(Target::ScenarioObject);
|
||||
let plan = group_removal::prepare(&project, owner, &targets).unwrap();
|
||||
assert_eq!(plan.removed, lanes.into_iter().collect());
|
||||
let mut expected = project.clone();
|
||||
for lane in lanes {
|
||||
expected.elements.remove(&lane);
|
||||
}
|
||||
for id in &messages {
|
||||
let SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
},
|
||||
..
|
||||
} = expected.elements.get_mut(id).unwrap().shared_mut().unwrap()
|
||||
else {
|
||||
panic!("surviving message");
|
||||
};
|
||||
*sender = None;
|
||||
*receiver = None;
|
||||
}
|
||||
oa_core::validate_candidate(&expected).unwrap();
|
||||
let mut unrestricted = Document::from_bundle(bundle.clone(), Default::default()).unwrap();
|
||||
unrestricted.execute(plan.command.clone()).unwrap();
|
||||
assert_eq!(unrestricted.project(), &expected);
|
||||
// Removal can grow losslessly edited source: each authored @a/@b becomes
|
||||
// explicit none in a surviving message. Prove net growth after deletion,
|
||||
// rather than assuming a budget failure or changing private Document limits.
|
||||
assert!(bytes(unrestricted.bundle()) > bytes(&bundle));
|
||||
let mut limits = ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bytes(&bundle);
|
||||
let mut bounded = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
let error = bounded.execute(plan.command).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.diagnostic.code == "source_limit")
|
||||
);
|
||||
let refusal = format!("Nothing removed: {error}");
|
||||
|
||||
for saved in [false, true] {
|
||||
for with_redo in [false, true] {
|
||||
let mut app = App::with_project(project.clone());
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
let redo = if with_redo {
|
||||
// A genuine earlier rename fits the same budget. Undo leaves
|
||||
// its exact model/source snapshot available before this review.
|
||||
app.document
|
||||
.execute(Command::Rename(owner, "Short".into()))
|
||||
.unwrap();
|
||||
let future = app.document.snapshot();
|
||||
assert!(app.document.undo());
|
||||
Some(future)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
app.sync();
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(if saved {
|
||||
Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
})
|
||||
} else {
|
||||
Message::Page(Page::Behavior)
|
||||
});
|
||||
for (id, choice) in [
|
||||
(lanes[1], multiselect::Choice::Replace),
|
||||
(lanes[0], multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
app.window_size = iced::Size::new(1600., 1000.);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.scenario_removal_enabled());
|
||||
let baseline = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
selected.targets(),
|
||||
&[targets[1].clone(), targets[0].clone()]
|
||||
);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let navigation = (
|
||||
app.page,
|
||||
app.active_diagram,
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.active_owner,
|
||||
);
|
||||
let editor = app.source_editor.text();
|
||||
assert!(!app.document.dirty() && !app.document.can_undo());
|
||||
assert_eq!(app.document.can_redo(), with_redo);
|
||||
let token = open(&mut app);
|
||||
let review = app.delete_target.as_ref().unwrap();
|
||||
assert!(app.scenario_removal_review_valid(review));
|
||||
assert_eq!(review.scenario.as_ref().unwrap().plan.removed, plan.removed);
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.status, refusal);
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
assert_ne!(app.canvas_epoch, epoch, "failed confirmation retires input");
|
||||
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.dirty() && !app.document.can_undo());
|
||||
assert_eq!(app.document.can_redo(), with_redo);
|
||||
let retained = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(retained.targets(), selected.targets());
|
||||
assert_eq!(retained.primary(), selected.primary());
|
||||
assert_eq!(retained.generation(), selected.generation());
|
||||
let after = app.scenario_context().unwrap();
|
||||
assert_eq!(after.document, context.document);
|
||||
assert_eq!(after.revision, context.revision);
|
||||
assert_eq!(after.representation, context.representation);
|
||||
assert_eq!(after.visit, context.visit);
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.active_diagram,
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.active_owner,
|
||||
),
|
||||
navigation
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), editor);
|
||||
assert_eq!(app.source_workspace.bundle(), baseline.bundle);
|
||||
assert!(!app.source_dirty() && !app.draft_dirty());
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), retained);
|
||||
assert_eq!(app.status, refusal);
|
||||
assert!(std::sync::Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
if let Some(future) = redo {
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &future.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &future.bundle);
|
||||
assert!(!app.document.can_redo());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_old_modal_packets_do_not_touch_the_new_review() {
|
||||
let mut f = setup(true);
|
||||
let old_begin = captured(&f.app);
|
||||
let first = open(&mut f.app);
|
||||
send(&mut f.app, Event::Cancel { token: first });
|
||||
let second = open(&mut f.app);
|
||||
assert_ne!(first, second);
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let source = f.app.document.bundle().clone();
|
||||
for message in [
|
||||
Message::ScenarioRemoval(Box::new(old_begin)),
|
||||
Message::ScenarioRemoval(Box::new(Event::Cancel { token: first })),
|
||||
Message::ScenarioRemoval(Box::new(Event::Query {
|
||||
token: first,
|
||||
query: "stale".into(),
|
||||
})),
|
||||
Message::ScenarioRemoval(Box::new(Event::Page {
|
||||
token: first,
|
||||
page: 99,
|
||||
})),
|
||||
Message::ConfirmDelete(first),
|
||||
Message::CancelDelete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
let target = f.app.delete_target.as_ref().unwrap();
|
||||
assert_eq!(target.snapshot.token, second);
|
||||
assert!(target.scenario.as_ref().unwrap().query.is_empty());
|
||||
assert_eq!(target.scenario.as_ref().unwrap().page, 0);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selected);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
}
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::Query {
|
||||
token: second,
|
||||
query: "reply".into(),
|
||||
},
|
||||
);
|
||||
send(
|
||||
&mut f.app,
|
||||
Event::Page {
|
||||
token: second,
|
||||
page: 1,
|
||||
},
|
||||
);
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
send(&mut f.app, Event::Cancel { token: second });
|
||||
assert!(!f.app.delete_pending);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_confirm_expires_for_each_context_or_selection_change() {
|
||||
for change in 0..5 {
|
||||
let mut f = setup(true);
|
||||
let token = open(&mut f.app);
|
||||
match change {
|
||||
0 => f.app.canvas_document = Id::new_v4(),
|
||||
1 => {
|
||||
let context = f.app.scenario_context().unwrap();
|
||||
let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., 560.),
|
||||
)));
|
||||
assert_ne!(f.app.canvas_epoch, context.epoch);
|
||||
}
|
||||
2 => {
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Rename(f.owner, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
3 => {
|
||||
let context = f.app.scenario_context().unwrap();
|
||||
f.app.behavior.scenario.enter_representation(
|
||||
context.document,
|
||||
f.owner,
|
||||
Representation::Derived { owner: f.owner },
|
||||
);
|
||||
f.app.behavior.scenario.enter_representation(
|
||||
context.document,
|
||||
f.owner,
|
||||
context.representation,
|
||||
);
|
||||
assert_ne!(f.app.scenario_context().unwrap().visit, context.visit);
|
||||
}
|
||||
_ => {
|
||||
let selected = f.app.diagram_selection.as_mut().unwrap();
|
||||
assert!(selected.set_primary(&Target::ScenarioObject(f.reply)));
|
||||
assert!(selected.set_primary(&Target::ScenarioObject(f.call)));
|
||||
}
|
||||
}
|
||||
let current = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::ConfirmDelete(token));
|
||||
assert_eq!(f.app.project(), ¤t.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), ¤t.bundle);
|
||||
assert!(!f.app.delete_pending && f.app.delete_target.is_none());
|
||||
assert!(f.app.status.contains("expired"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_cancel_preserves_existing_redo_and_full_selection() {
|
||||
let mut f = setup(true);
|
||||
let _ = f.app.update(Message::Command(Command::Rename(
|
||||
f.tail,
|
||||
"Redo remains available".into(),
|
||||
)));
|
||||
let renamed = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert!(f.app.document.can_redo());
|
||||
// Rename inspects the renamed tail; Undo preserves that inspection. Restore
|
||||
// the real group before testing cancellation of its removal review.
|
||||
for (id, choice) in [
|
||||
(f.reply, multiselect::Choice::Replace),
|
||||
(f.call, multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: f.app.scenario_context().unwrap(),
|
||||
snapshot: f.app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selected.targets().len(), 2);
|
||||
assert!(f.app.document.can_redo());
|
||||
let source = f.app.document.bundle().clone();
|
||||
let token = open(&mut f.app);
|
||||
send(&mut f.app, Event::Cancel { token });
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
assert!(f.app.document.can_redo() && !f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.document.bundle(), &renamed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_does_not_retire_active_reorder_or_spacing() {
|
||||
for spacing in [false, true] {
|
||||
let mut f = setup(true);
|
||||
if spacing {
|
||||
let event = crate::app::scenario_spacing::tests::packet_for_app(
|
||||
&f.app,
|
||||
scenario::spacing::Target::Message(f.call),
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
assert!(f.app.behavior.scenario.spacing_token().is_some());
|
||||
} else {
|
||||
let event = crate::app::scenario_reorder::Event {
|
||||
context: f.app.scenario_context().unwrap(),
|
||||
snapshot: f.app.multiselect_snapshot().unwrap(),
|
||||
token: Id::new_v4(),
|
||||
intent: crate::app::scenario_reorder::Intent::Begin,
|
||||
};
|
||||
let _ = f.app.update(Message::ScenarioReorder(Box::new(event)));
|
||||
assert!(f.app.scenario_reorder.session.is_some());
|
||||
}
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let spacing_token = f.app.behavior.scenario.spacing_token();
|
||||
let reorder_token = f
|
||||
.app
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|session| session.token());
|
||||
let source = f.app.document.bundle().clone();
|
||||
let event = captured(&f.app);
|
||||
send(&mut f.app, event);
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.behavior.scenario.spacing_token(), spacing_token);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_ref()
|
||||
.map(|session| session.token()),
|
||||
reorder_token
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_menu_does_not_select_its_clicked_subject() {
|
||||
for unselected in [false, true] {
|
||||
let mut f = setup(true);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let clicked = if unselected { f.tail } else { f.reply };
|
||||
let mut request = model_menu::Request::object(clicked, iced::Point::ORIGIN);
|
||||
request.scenario = f.app.scenario_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(clicked)));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(model_menu::Event::Open(request)));
|
||||
let menu = f.app.model_menu.as_ref().expect("Scenario menu");
|
||||
let token = menu.token;
|
||||
let action = begin(&f.app).expect("captured group menu action");
|
||||
let _ = f.app.update(Message::Menu(model_menu::Event::Invoke(
|
||||
token,
|
||||
Box::new(action),
|
||||
)));
|
||||
let review = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.scenario
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!(review.snapshot.targets(), selection.targets());
|
||||
assert_eq!(review.snapshot.primary(), selection.primary());
|
||||
assert_eq!(f.app.selected, f.call);
|
||||
assert!(!review.plan.removed.contains(&f.tail));
|
||||
assert!(f.app.project().contains(clicked));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_removal_rejects_dirty_properties_and_legacy_singleton_messages() {
|
||||
let mut f = setup(false);
|
||||
let baseline = f.app.document.bundle().clone();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let epoch = f.app.canvas_epoch;
|
||||
let _ = f.app.update(Message::Delete);
|
||||
let _ = f.app.update(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
f.app.scenario_context().unwrap(),
|
||||
f.call,
|
||||
)));
|
||||
assert!(f.app.delete_target.is_none());
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap(), selected);
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied group primary".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let event = captured(&f.app);
|
||||
let epoch = f.app.canvas_epoch;
|
||||
send(&mut f.app, event);
|
||||
assert!(f.app.delete_target.is_none() && f.app.draft_dirty());
|
||||
assert_eq!(f.app.canvas_epoch, epoch);
|
||||
assert_eq!(f.app.document.bundle(), &baseline);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_group_delete_captures_whole_set_and_repeat_does_not_confirm() {
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{
|
||||
keyboard::{self, key::Named},
|
||||
mouse,
|
||||
};
|
||||
for shift in [false, true] {
|
||||
let mut f = setup(true);
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
mounted
|
||||
.batch(&mut f.app, &[key(Named::F6)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let mut remove = key(Named::Delete);
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut remove {
|
||||
*modifiers = if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::default()
|
||||
};
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut f.app, &[remove.clone()], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(output.iter().any(|message| matches!(message, Message::ScenarioRemoval(event) if matches!(&**event, Event::Begin { snapshot, .. } if snapshot.targets()==selected.targets() && snapshot.primary()==selected.primary()))), "actual canvas Delete emits full captured group: {output:?}");
|
||||
assert!(!output.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::Delete | Message::ScenarioCanvas(CanvasEvent::Remove(..))
|
||||
)));
|
||||
let token = f
|
||||
.app
|
||||
.delete_target
|
||||
.as_ref()
|
||||
.expect("mounted group review")
|
||||
.snapshot
|
||||
.token;
|
||||
if let iced::Event::Keyboard(keyboard::Event::KeyPressed { repeat, .. }) = &mut remove {
|
||||
*repeat = true;
|
||||
}
|
||||
let output = mounted
|
||||
.batch(&mut f.app, &[remove], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!output
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ConfirmDelete(_)))
|
||||
);
|
||||
assert_eq!(f.app.delete_target.as_ref().unwrap().snapshot.token, token);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
send(&mut f.app, Event::Cancel { token });
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//! Review the complete captured removal impact before one explicit confirmation.
|
||||
use super::{Event, Review};
|
||||
use crate::app::{App, Message, diagram_removal};
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::style;
|
||||
use iced::widget::{button, column, container, row, scrollable, text, text_input};
|
||||
use iced::{Element, Length::Fill};
|
||||
use oa_core::{Id, Project};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const PAGE_SIZE: usize = 6;
|
||||
|
||||
struct ImpactRow {
|
||||
label: String,
|
||||
disposition: &'static str,
|
||||
}
|
||||
|
||||
fn matching_rows(project: &Project, review: &Review) -> Vec<ImpactRow> {
|
||||
let aliases: BTreeMap<Id, &str> = project.identities().into_iter().collect();
|
||||
let query = review.query.trim().to_lowercase();
|
||||
review
|
||||
.plan
|
||||
.removed
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let selected = review
|
||||
.snapshot
|
||||
.targets()
|
||||
.contains(&Target::ScenarioObject(*id));
|
||||
(
|
||||
*id,
|
||||
if selected {
|
||||
"Removed · selected"
|
||||
} else {
|
||||
"Additional removed"
|
||||
},
|
||||
)
|
||||
})
|
||||
.chain(
|
||||
review
|
||||
.plan
|
||||
.changed
|
||||
.iter()
|
||||
.map(|id| (*id, "Updated · preserved")),
|
||||
)
|
||||
.filter_map(|(id, disposition)| {
|
||||
let name = project.named(id);
|
||||
let identity = aliases
|
||||
.get(&id)
|
||||
.map_or_else(|| id.to_string(), |alias| format!("@{alias}"));
|
||||
let label = if name == "Missing object" || name.is_empty() {
|
||||
identity
|
||||
} else {
|
||||
format!("{name} ({identity})")
|
||||
};
|
||||
(query.is_empty()
|
||||
|| label.to_lowercase().contains(&query)
|
||||
|| id.to_string().contains(&query))
|
||||
.then_some(ImpactRow { label, disposition })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn view<'a>(
|
||||
app: &'a App,
|
||||
target: &'a diagram_removal::Confirmation,
|
||||
) -> Element<'a, Message> {
|
||||
let Some(review) = target.scenario.as_ref() else {
|
||||
return text("Scenario removal review is unavailable.").into();
|
||||
};
|
||||
let token = target.snapshot.token;
|
||||
let valid = app.scenario_removal_review_valid(target);
|
||||
let rows = matching_rows(app.project(), review);
|
||||
let pages = rows.len().div_ceil(PAGE_SIZE).max(1);
|
||||
let page = review.page.min(pages - 1);
|
||||
let mut impacts = column![].spacing(12).width(Fill);
|
||||
for impact in rows.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
impacts = impacts.push(
|
||||
column![
|
||||
text(impact.disposition).size(12).color(style::MUTED),
|
||||
text(impact.label.clone())
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
impacts = impacts.push(text("No matching affected records. Try a name or alias.").size(13));
|
||||
}
|
||||
let previous = button("Previous").on_press_maybe((page > 0).then(|| {
|
||||
Message::ScenarioRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page - 1,
|
||||
}))
|
||||
}));
|
||||
let next = button("Next").on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::ScenarioRemoval(Box::new(Event::Page {
|
||||
token,
|
||||
page: page + 1,
|
||||
}))
|
||||
}));
|
||||
let mut footer = column![].spacing(8).width(Fill);
|
||||
if !valid {
|
||||
footer = footer.push(
|
||||
text("This review expired. Cancel and review the current selection again.")
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
}
|
||||
footer = footer.push(
|
||||
row![
|
||||
container(
|
||||
button("Cancel")
|
||||
.padding(10)
|
||||
.on_press(Message::ScenarioRemoval(Box::new(Event::Cancel { token })))
|
||||
)
|
||||
.id("delete-cancel"),
|
||||
container(
|
||||
button("Remove from Scenario")
|
||||
.style(button::danger)
|
||||
.padding(10)
|
||||
.on_press_maybe(valid.then_some(Message::ConfirmDelete(token)))
|
||||
)
|
||||
.id("delete-confirm"),
|
||||
]
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
text(format!("Remove {} Scenario objects?", review.snapshot.targets().len())).size(21),
|
||||
text(format!("{} records removed · {} records updated", review.plan.removed.len(), review.plan.changed.len())).size(14),
|
||||
text_input("Search affected names or aliases", &review.query)
|
||||
.id("scenario-removal-query")
|
||||
.on_input(move |query| Message::ScenarioRemoval(Box::new(Event::Query { token, query })))
|
||||
.size(13),
|
||||
row![previous, text(format!("Page {} of {} · {} matches", page + 1, pages, rows.len())).size(12), next]
|
||||
.spacing(10).align_y(iced::Alignment::Center),
|
||||
scrollable(column![
|
||||
text("Removes local occurrences or participants and their owned descendants. Affected references are cleared; reusable definitions are preserved. Updated records include owning sequences and affected views. One Undo restores the entire change.")
|
||||
.size(13).width(Fill).wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
impacts,
|
||||
].spacing(14).width(Fill))
|
||||
.width(Fill).height(Fill).spacing(8).id("scenario-removal-impact"),
|
||||
footer,
|
||||
]
|
||||
.spacing(12)
|
||||
.width(Fill)
|
||||
// At the reviewed 732px window height, 570px content plus the modal's
|
||||
// 52px padding leaves 110px outside the card. Only the impact body flexes.
|
||||
.height((app.window_size.height - 160.).clamp(260., 570.))
|
||||
.into()
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
//! Exact-input adapter for an explicit semantic reorder operation.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::presentation::PresentationStatus;
|
||||
use crate::{
|
||||
behavior::scenario::{
|
||||
self,
|
||||
reorder_session::{Outcome, Session},
|
||||
selection,
|
||||
},
|
||||
diagram_selection::Snapshot,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
pub(crate) session: Option<Session>,
|
||||
pub(crate) query: String,
|
||||
pub(crate) page: usize,
|
||||
pub(crate) summary: Option<(scenario::Context, String)>,
|
||||
summary_selection: Option<Snapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Event {
|
||||
pub(crate) context: scenario::Context,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) token: Id,
|
||||
pub(crate) intent: Intent,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Intent {
|
||||
Begin,
|
||||
Choose(scenario::Slot),
|
||||
Query(String),
|
||||
Page(usize),
|
||||
Finish(scenario::Slot),
|
||||
Cancel,
|
||||
}
|
||||
|
||||
fn short_label(value: &str) -> String {
|
||||
crate::views::short(value, 24)
|
||||
}
|
||||
|
||||
/// Actual committed block order, independent of capture order or saved Y.
|
||||
fn result_neighborhood(project: &oa_core::Project, parent: Id, snapshot: &Snapshot) -> String {
|
||||
let Some(order) = project
|
||||
.elements
|
||||
.get(&parent)
|
||||
.and_then(|element| element.steps())
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
let selected: Vec<_> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, id)| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(**id))
|
||||
})
|
||||
.collect();
|
||||
let Some((first, _)) = selected.first() else {
|
||||
return String::new();
|
||||
};
|
||||
let last = selected.last().expect("nonempty block").0;
|
||||
let label = |index: usize| format!("#{} @{}", index + 1, project.elements[&order[index]].alias);
|
||||
let left = first
|
||||
.checked_sub(1)
|
||||
.map_or_else(|| "Start".to_owned(), label);
|
||||
let right = if last + 1 < order.len() {
|
||||
label(last + 1)
|
||||
} else {
|
||||
"End".to_owned()
|
||||
};
|
||||
let mut members = selected
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|(index, _)| label(*index))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if selected.len() > 3 {
|
||||
members.push_str(&format!(", +{} more", selected.len() - 3));
|
||||
}
|
||||
format!("{left} → [{members}] → {right}")
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn scenario_reorder_enabled(&self) -> bool {
|
||||
self.spacing_enabled()
|
||||
&& diagram_removal::enabled(self)
|
||||
&& self.behavior.scenario.spacing_token().is_none()
|
||||
&& self.scenario_context().is_some_and(|context| {
|
||||
self.behavior.scenario.canvas_tool(context.scenario) == scenario::CanvasTool::Select
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_reorder_cancel(&mut self) {
|
||||
if self.scenario_reorder.session.take().is_some() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.scenario_reorder.query.clear();
|
||||
self.scenario_reorder.page = 0;
|
||||
self.scenario_reorder.summary = None;
|
||||
self.scenario_reorder.summary_selection = None;
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_reorder_reconcile(&mut self) {
|
||||
if self
|
||||
.scenario_reorder
|
||||
.summary
|
||||
.as_ref()
|
||||
.is_some_and(|(context, _)| {
|
||||
// A committed result is presentation, not a live authoring packet.
|
||||
// Resizing diagnostics or moving the camera retires input epochs
|
||||
// without changing the result's document, view or selection.
|
||||
let same_result = self.scenario_context().is_some_and(|mut current| {
|
||||
current.epoch = context.epoch;
|
||||
current == *context
|
||||
});
|
||||
let same_selection = self
|
||||
.scenario_reorder
|
||||
.summary_selection
|
||||
.as_ref()
|
||||
.zip(self.diagram_selection.as_ref())
|
||||
.is_some_and(|(snapshot, selected)| {
|
||||
selected.matches(snapshot, snapshot.context())
|
||||
});
|
||||
!same_result || !same_selection
|
||||
})
|
||||
{
|
||||
self.scenario_reorder.summary = None;
|
||||
self.scenario_reorder.summary_selection = None;
|
||||
}
|
||||
let Some(session) = self.scenario_reorder.session.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let valid = self.scenario_reorder_enabled()
|
||||
&& self
|
||||
.scenario_context()
|
||||
.zip(self.multiselect_snapshot())
|
||||
.is_some_and(|(context, snapshot)| {
|
||||
session.matches(self.project(), context, &snapshot, session.token())
|
||||
});
|
||||
if !valid {
|
||||
self.scenario_reorder_cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scenario_reorder_event(&mut self, event: Event) -> Task<Message> {
|
||||
// Reject stale envelopes before touching any newer operation or UI state.
|
||||
if event.token.is_nil()
|
||||
|| self.scenario_context() != Some(event.context)
|
||||
|| !self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection_state| {
|
||||
selection_state.matches(&event.snapshot, selection::context(event.context))
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(&event.intent, Intent::Begin) {
|
||||
if self.scenario_reorder.session.is_some() || !self.scenario_reorder_enabled() {
|
||||
return Task::none();
|
||||
}
|
||||
// Retire the captured Begin before construction, including failed starts.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.scenario_context().expect("admitted scenario");
|
||||
let snapshot = self.multiselect_snapshot().expect("admitted selection");
|
||||
self.scenario_reorder.query.clear();
|
||||
self.scenario_reorder.page = 0;
|
||||
self.scenario_reorder.summary = None;
|
||||
self.scenario_reorder.summary_selection = None;
|
||||
match Session::new(
|
||||
self.document.snapshot().decoded.project.clone().into(),
|
||||
context,
|
||||
snapshot,
|
||||
event.token,
|
||||
) {
|
||||
Ok(session) => {
|
||||
self.scenario_reorder.session = Some(session);
|
||||
self.status = "Choose a semantic destination, then apply reorder. Saved positions are preserved.".into();
|
||||
return scenario::reorder_view::focus_query(event.token);
|
||||
}
|
||||
Err(reason) => self.status = reason,
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
let Some(session) = self.scenario_reorder.session.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
if !session.matches(self.project(), event.context, &event.snapshot, event.token) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Intent::Finish(slot) = &event.intent
|
||||
&& session
|
||||
.current_preview()
|
||||
.is_none_or(|preview| preview.slot != *slot)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(&event.intent, Intent::Cancel) {
|
||||
self.scenario_reorder_cancel();
|
||||
self.status = "Reorder cancelled. Sequence unchanged.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if !self.scenario_reorder_enabled() {
|
||||
self.scenario_reorder_cancel();
|
||||
return Task::none();
|
||||
}
|
||||
match event.intent {
|
||||
Intent::Choose(slot) => {
|
||||
let preview = self
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_mut()
|
||||
.expect("admitted session")
|
||||
.preview(slot);
|
||||
self.status = match &preview.outcome {
|
||||
Outcome::Refused(reason) => reason.clone(),
|
||||
Outcome::Noop => "This destination leaves the semantic order unchanged.".into(),
|
||||
Outcome::Change => match preview.presentation.as_ref().map(|shown| shown.status()) {
|
||||
Some(PresentationStatus::Ready) => "Review the semantic order, then apply reorder. Saved positions are preserved.".into(),
|
||||
Some(PresentationStatus::Fallback { .. }) => "The new semantic order requires automatic display fallback. Apply keeps the requested saved view and its authored positions.".into(),
|
||||
_ => "The candidate cannot be displayed; reorder cannot be applied.".into(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Intent::Query(query) => {
|
||||
self.scenario_reorder.query = query;
|
||||
self.scenario_reorder.page = 0;
|
||||
}
|
||||
Intent::Page(page) => self.scenario_reorder.page = page,
|
||||
Intent::Finish(slot) => {
|
||||
let label = |id| {
|
||||
self.project().elements.get(&id).map_or_else(
|
||||
|| id.to_string(),
|
||||
|element| {
|
||||
format!(
|
||||
"{} (@{})",
|
||||
short_label(&element.name),
|
||||
short_label(&element.alias)
|
||||
)
|
||||
},
|
||||
)
|
||||
};
|
||||
let parent_name = label(slot.parent);
|
||||
let destination = slot.before.map_or_else(
|
||||
|| "at the end".to_owned(),
|
||||
|anchor| format!("before {}", label(anchor)),
|
||||
);
|
||||
let session = self
|
||||
.scenario_reorder
|
||||
.session
|
||||
.take()
|
||||
.expect("admitted session");
|
||||
let selection_visible = session
|
||||
.current_preview()
|
||||
.is_some_and(|preview| preview.selection_visible);
|
||||
let result =
|
||||
session.finish(self.project(), event.context, &event.snapshot, event.token);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.scenario_reorder.query.clear();
|
||||
self.scenario_reorder.page = 0;
|
||||
match result {
|
||||
Err(reason) => self.status = reason,
|
||||
Ok(completion) => {
|
||||
debug_assert_eq!(completion.representation, event.context.representation);
|
||||
if !selection_visible {
|
||||
self.status = "The candidate would hide a selected occurrence; reorder was not applied.".into();
|
||||
} else if matches!(
|
||||
completion.presentation_status,
|
||||
PresentationStatus::Unavailable { .. }
|
||||
) {
|
||||
self.status =
|
||||
"The candidate cannot be displayed; reorder was not applied."
|
||||
.into();
|
||||
} else if let Some(command) = completion.command {
|
||||
match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
if let Some(crate::diagram_usage::Target::ScenarioObject(
|
||||
primary,
|
||||
)) = completion.snapshot.primary()
|
||||
{
|
||||
self.selected = *primary;
|
||||
}
|
||||
self.sync();
|
||||
self.reconcile_multiselect();
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
let mut summary = format!(
|
||||
"Moved {} selected occurrences {} in {}. {}. Saved positions are preserved.",
|
||||
completion.snapshot.targets().len(),
|
||||
destination,
|
||||
parent_name,
|
||||
result_neighborhood(
|
||||
self.project(),
|
||||
slot.parent,
|
||||
&completion.snapshot
|
||||
)
|
||||
);
|
||||
if matches!(
|
||||
completion.presentation_status,
|
||||
PresentationStatus::Fallback { .. }
|
||||
) {
|
||||
summary.push_str(" Automatic display fallback is active.");
|
||||
}
|
||||
self.scenario_reorder.summary =
|
||||
self.scenario_context().map(|context| (context, summary));
|
||||
self.scenario_reorder.summary_selection =
|
||||
self.multiselect_snapshot();
|
||||
self.status = if matches!(
|
||||
completion.presentation_status,
|
||||
PresentationStatus::Fallback { .. }
|
||||
) {
|
||||
"Semantic order updated. Automatic display fallback is active; the requested saved view and authored positions are preserved.".into()
|
||||
} else {
|
||||
"Semantic order updated. Saved positions are preserved."
|
||||
.into()
|
||||
};
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!("Reorder was not applied: {error}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.status = "Semantic order unchanged.".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Intent::Begin | Intent::Cancel => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summary_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,231 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, Slot, direct_tests};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
fn committed() -> (App, Id, Id, oa_core::source_project::SourceBundle) {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let mut extra = project
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Tail", Some(f.owner))
|
||||
.unwrap();
|
||||
if let Some(oa_core::SharedBody::Occurrence { body, .. }) = extra.shared_mut() {
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
}
|
||||
let extra_id = extra.id;
|
||||
project.elements.insert(extra_id, extra);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(extra_id);
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "summary_view".into(),
|
||||
name: "Summary view".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout::default())),
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(f.owner));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Summary view".into(),
|
||||
}));
|
||||
for (id, choice) in [
|
||||
(f.call, multiselect::Choice::Replace),
|
||||
(f.reply, multiselect::Choice::Add),
|
||||
] {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
viewport(&mut app, 700.);
|
||||
let baseline = app.document.bundle().clone();
|
||||
dispatch(&mut app, Intent::Begin);
|
||||
let slot = Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
};
|
||||
dispatch(&mut app, Intent::Choose(slot));
|
||||
dispatch(&mut app, Intent::Finish(slot));
|
||||
assert_eq!(
|
||||
app.project().elements[&f.owner].steps().unwrap(),
|
||||
&[extra_id, f.call, f.reply]
|
||||
);
|
||||
assert!(app.scenario_reorder.summary.is_some(), "{}", app.status);
|
||||
(app, f.owner, f.call, baseline)
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut App, intent: Intent) {
|
||||
let event = Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
token: app
|
||||
.scenario_reorder
|
||||
.session
|
||||
.as_ref()
|
||||
.map_or_else(Id::new_v4, Session::token),
|
||||
intent,
|
||||
};
|
||||
let _ = app.update(Message::ScenarioReorder(Box::new(event)));
|
||||
}
|
||||
|
||||
fn viewport(app: &mut App, height: f32) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
iced::Size::new(900., height),
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_survives_diagnostics_resize_and_camera_then_undo_clears() {
|
||||
let (mut app, _, _, baseline) = committed();
|
||||
let summary = app.scenario_reorder.summary.clone();
|
||||
let changed = app.document.bundle().clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
viewport(&mut app, 660.);
|
||||
assert_ne!(
|
||||
app.canvas_epoch, epoch,
|
||||
"real diagnostics-height viewport change must retire input"
|
||||
);
|
||||
assert_eq!(app.scenario_reorder.summary, summary);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
app.camera_epoch,
|
||||
crate::behavior::canvas_workspace::Event::Pan(iced::Vector::new(24., 12.)),
|
||||
)));
|
||||
assert!(app.behavior.scenario.camera_boundary() != camera);
|
||||
assert_eq!(app.scenario_reorder.summary, summary);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
assert!(app.scenario_reorder.summary_selection.is_none());
|
||||
assert_eq!(app.document.bundle(), &baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_retires_on_view_selection_and_new_operation() {
|
||||
for action in 0..3 {
|
||||
let (mut app, owner, call, _) = committed();
|
||||
let source = app.document.bundle().clone();
|
||||
match action {
|
||||
0 => {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Derived { owner },
|
||||
)));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Derived { owner }
|
||||
);
|
||||
}
|
||||
1 => {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Collapse(Target::ScenarioObject(call)),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
_ => {
|
||||
dispatch(&mut app, Intent::Begin);
|
||||
assert!(app.scenario_reorder.session.is_some());
|
||||
dispatch(&mut app, Intent::Cancel);
|
||||
assert!(app.scenario_reorder.session.is_none());
|
||||
assert_eq!(app.status, "Reorder cancelled. Sequence unchanged.");
|
||||
}
|
||||
}
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
assert!(app.scenario_reorder.summary_selection.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_selection_generation_aba_is_not_a_camera_change() {
|
||||
let (mut app, _, call, _) = committed();
|
||||
let captured = app.multiselect_snapshot().unwrap();
|
||||
let selected = app.diagram_selection.as_mut().unwrap();
|
||||
assert!(selected.set_primary(&Target::ScenarioObject(call)));
|
||||
assert!(selected.set_primary(captured.primary().unwrap()));
|
||||
assert_eq!(selected.targets(), captured.targets());
|
||||
assert_eq!(selected.primary(), captured.primary());
|
||||
app.scenario_reorder_reconcile();
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_reorder_summary_clears_after_real_spacing_commit_without_another_event() {
|
||||
use crate::behavior::scenario::spacing::{Event as Spacing, Target as SpacingTarget};
|
||||
let (mut app, owner, call, _) = committed();
|
||||
let reordered = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let begin =
|
||||
crate::app::scenario_spacing::tests::packet_for_app(&app, SpacingTarget::Message(call));
|
||||
let Spacing::Begin { press_screen, .. } = begin else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
|
||||
assert!(app.scenario_reorder.summary.is_some());
|
||||
let screen = press_screen + iced::Vector::new(0., 24.);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
Spacing::Move {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
},
|
||||
)));
|
||||
assert_eq!(app.document.bundle(), &reordered.bundle);
|
||||
let finish = Spacing::Finish {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(finish)));
|
||||
assert_ne!(
|
||||
app.document.bundle(),
|
||||
&reordered.bundle,
|
||||
"spacing must actually commit: {}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().elements[&owner].steps(),
|
||||
reordered.decoded.project.elements[&owner].steps()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert!(app.scenario_reorder.summary.is_none());
|
||||
assert!(app.scenario_reorder.summary_selection.is_none());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &reordered.bundle);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,9 @@
|
||||
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use behavior::scenario::{
|
||||
selection,
|
||||
spacing::{Event, Refusal, controller::Finish},
|
||||
};
|
||||
use behavior::scenario::spacing::{Event, controller::Finish};
|
||||
|
||||
#[cfg(test)]
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
pub(super) mod tests;
|
||||
|
||||
fn refusal_reason(refusal: &Refusal) -> String {
|
||||
match refusal {
|
||||
Refusal::InvalidSelection(reason) => (*reason).into(),
|
||||
Refusal::TargetNotRendered(_) => {
|
||||
"Every selected object must be a rendered local message or lifeline in this saved view."
|
||||
.into()
|
||||
}
|
||||
Refusal::Geometry(_) => {
|
||||
"These positions cannot be displayed together. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
Refusal::Command(reason) => reason.clone(),
|
||||
_ => {
|
||||
"The drag could not be applied in this view. The saved arrangement is unchanged.".into()
|
||||
}
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn spacing_enabled(&self) -> bool {
|
||||
@@ -40,54 +16,22 @@ impl App {
|
||||
}
|
||||
|
||||
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
||||
if self.scenario_reorder.session.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
let stamp = event.stamp();
|
||||
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(snapshot) = self.multiselect_snapshot() else {
|
||||
return Task::none();
|
||||
};
|
||||
if snapshot.generation() != stamp.selection_generation
|
||||
|| snapshot.context() != selection::context(stamp.context)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let state = &self.behavior.scenario;
|
||||
let matches =
|
||||
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
|
||||
if match event {
|
||||
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
|
||||
_ => {
|
||||
!matches
|
||||
|| !state.spacing_snapshot().is_some_and(|captured| {
|
||||
self.diagram_selection.as_ref().is_some_and(|selected| {
|
||||
selected.matches(captured, selection::context(stamp.context))
|
||||
})
|
||||
})
|
||||
}
|
||||
_ => !matches,
|
||||
} {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Begin { .. }) {
|
||||
let visible = selection::visible(self.project(), stamp.context, self.selected);
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target))
|
||||
{
|
||||
// This otherwise current Begin cannot be replayed after repair.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status =
|
||||
"Every selected object must still be visible before moving the group.".into();
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
|
||||
if self.behavior.scenario.cancel_spacing() || matches!(event, Event::Begin { .. }) {
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
@@ -95,38 +39,18 @@ impl App {
|
||||
match event {
|
||||
Event::Begin { .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_spacing_with_snapshot(self.project(), event, snapshot);
|
||||
let result = state.begin_spacing(self.project(), event);
|
||||
self.behavior.scenario = state;
|
||||
if let Err(error) = result {
|
||||
// Consume admitted failed starts too; a repeated Begin is stale.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = refusal_reason(&error);
|
||||
if result.is_err() {
|
||||
self.status = "The drag could not start in this view. Try again.".into();
|
||||
}
|
||||
}
|
||||
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
|
||||
Event::Finish { screen, .. } => {
|
||||
// Full captured selection matched above, before taking the session.
|
||||
let captured = self
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_snapshot()
|
||||
.cloned()
|
||||
.expect("admitted session snapshot");
|
||||
let group = captured.targets().len() > 1;
|
||||
let finished = self.behavior.scenario.finish_spacing(screen);
|
||||
// Retire queued packets even for zero motion or a refused finish.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
match finished {
|
||||
Ok(Finish::Click(target)) if group => {
|
||||
let target = Target::ScenarioObject(target);
|
||||
// The controller only captures a pressed member of this set.
|
||||
if captured.targets().contains(&target) {
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.expect("admitted selection")
|
||||
.replace(target, |_| true);
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}
|
||||
}
|
||||
Ok(Finish::Click(target)) => self.select(target),
|
||||
Ok(Finish::Noop) => {}
|
||||
Ok(Finish::Commit {
|
||||
@@ -134,38 +58,28 @@ impl App {
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
}) => match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
self.selected = if group {
|
||||
match captured.primary() {
|
||||
Some(Target::ScenarioObject(id)) => *id,
|
||||
_ => selected,
|
||||
}
|
||||
} else {
|
||||
selected
|
||||
};
|
||||
self.selected_node = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.behavior.scenario.accept_spacing_camera(scale, pan);
|
||||
self.sync();
|
||||
if group {
|
||||
self.inspect_scenario_multiselect_primary();
|
||||
}) => {
|
||||
// Document prepares and writes the source candidate before
|
||||
// committing its snapshot. Failure leaves camera/selection
|
||||
// and source/history at their accepted values.
|
||||
match self.document.execute(*command) {
|
||||
Ok(_) => {
|
||||
self.selected = selected;
|
||||
self.selected_node = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.behavior.scenario.accept_spacing_camera(scale, pan);
|
||||
self.sync();
|
||||
self.status = "Scenario spacing updated.".into();
|
||||
}
|
||||
self.status = if group {
|
||||
format!(
|
||||
"Spacing updated for {} selected objects. Scenario order is unchanged.",
|
||||
captured.targets().len()
|
||||
)
|
||||
} else {
|
||||
"Scenario spacing updated.".into()
|
||||
};
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
},
|
||||
Err(error) => self.status = refusal_reason(&error),
|
||||
}
|
||||
Err(_) => {
|
||||
self.status =
|
||||
"That position cannot be applied. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.scenario_reorder_reconcile();
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
use super::tests::{begin, finish, move_to, packet_for_app, send, setup};
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection, spacing::Target};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::Target as SelectedTarget;
|
||||
use iced::Vector;
|
||||
use oa_core::{
|
||||
DiagramLayout, OccurrenceEndpoint, ScenarioLanePosition, ScenarioLayout,
|
||||
ScenarioMessagePosition,
|
||||
};
|
||||
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn choose(app: &mut App, id: Id, choice: multiselect::Choice) {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(SelectedTarget::ScenarioObject(id), choice),
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(
|
||||
event,
|
||||
))));
|
||||
}
|
||||
fn group(messages: bool, reverse: bool) -> (App, [Id; 2]) {
|
||||
let (mut app, lane, message) = setup();
|
||||
let ids = if messages {
|
||||
[message, id(&app, "second")]
|
||||
} else {
|
||||
[lane, id(&app, "operator")]
|
||||
};
|
||||
let order = if reverse { [ids[1], ids[0]] } else { ids };
|
||||
for member in order {
|
||||
choose(&mut app, member, multiselect::Choice::Add);
|
||||
}
|
||||
(app, ids)
|
||||
}
|
||||
fn target(messages: bool, id: Id) -> Target {
|
||||
if messages {
|
||||
Target::Message(id)
|
||||
} else {
|
||||
Target::Lane(id)
|
||||
}
|
||||
}
|
||||
fn delta(messages: bool, pixels: f32) -> Vector {
|
||||
if messages {
|
||||
Vector::new(0., pixels)
|
||||
} else {
|
||||
Vector::new(pixels, 0.)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn either_pressed_member_moves_complete_group_and_preserves_primary_source_and_one_history_step() {
|
||||
for messages in [false, true] {
|
||||
for pressed_index in 0..2 {
|
||||
let (mut app, ids) = group(messages, true);
|
||||
// Automatic lane order depends on generated identities. Give this
|
||||
// literal geometry oracle explicit authored starting coordinates.
|
||||
let c = app.scenario_context().unwrap();
|
||||
let Representation::Saved { diagram } = c.representation else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut initial = app.project().clone();
|
||||
let mut members = ids.to_vec();
|
||||
members.sort();
|
||||
let mut layout = ScenarioLayout::default();
|
||||
for member in members {
|
||||
if messages {
|
||||
layout.messages.push(ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: member,
|
||||
},
|
||||
send_y: if member == ids[0] { 148. } else { 240. },
|
||||
});
|
||||
} else {
|
||||
layout.lanes.push(ScenarioLanePosition {
|
||||
lifeline: member,
|
||||
x: if member == ids[0] { 160. } else { 400. },
|
||||
});
|
||||
}
|
||||
}
|
||||
initial.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(layout));
|
||||
app.document =
|
||||
Document::from_project(initial, oa_core::source_project::ProjectLimits::default())
|
||||
.unwrap();
|
||||
app.sync();
|
||||
let original = app.document.snapshot();
|
||||
let original_selection = app.multiselect_snapshot().unwrap();
|
||||
let primary = app.selected;
|
||||
let c = app.scenario_context().unwrap();
|
||||
let Representation::Saved { diagram } = c.representation else {
|
||||
unreachable!()
|
||||
};
|
||||
let (event, start, scale) = begin(&mut app, target(messages, ids[pressed_index]));
|
||||
let world_delta = 24. / scale;
|
||||
move_to(&mut app, event, start + delta(messages, 24.));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.spacing_snapshot(),
|
||||
Some(&original_selection)
|
||||
);
|
||||
let mut expected = original.decoded.project.clone();
|
||||
let mut members = ids.to_vec();
|
||||
members.sort();
|
||||
let mut layout = ScenarioLayout::default();
|
||||
for member in members {
|
||||
if messages {
|
||||
let y: f32 = if member == ids[0] { 148. } else { 240. };
|
||||
layout.messages.push(ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: member,
|
||||
},
|
||||
send_y: (f64::from(y) + f64::from(world_delta)) as f32,
|
||||
});
|
||||
} else {
|
||||
let x: f32 = if member == ids[0] { 160. } else { 400. };
|
||||
layout.lanes.push(ScenarioLanePosition {
|
||||
lifeline: member,
|
||||
x: (f64::from(x) + f64::from(world_delta)) as f32,
|
||||
});
|
||||
}
|
||||
}
|
||||
expected.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(layout));
|
||||
finish(&mut app, event, start + delta(messages, 24.));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
original_selection.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
original_selection.primary()
|
||||
);
|
||||
let saved = app.document.bundle().clone();
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let _ = app.update(Message::ShortcutUndo(true));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &saved);
|
||||
let reopened =
|
||||
Document::from_bundle(saved, oa_core::source_project::ProjectLimits::default())
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_stationary_click_collapses_but_offaxis_outback_cancel_preserve_group_and_redo() {
|
||||
for messages in [false, true] {
|
||||
for mode in 0..4 {
|
||||
let (mut app, ids) = group(messages, true);
|
||||
let (seed, start, _) = begin(&mut app, target(messages, ids[0]));
|
||||
finish(&mut app, seed, start + delta(messages, 20.));
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert!(app.document.can_redo());
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let primary = app.selected;
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, target(messages, ids[1]));
|
||||
match mode {
|
||||
0 => finish(&mut app, event, start),
|
||||
1 => finish(&mut app, event, start + delta(!messages, 50.)),
|
||||
2 => {
|
||||
move_to(&mut app, event, start + delta(messages, 30.));
|
||||
finish(&mut app, event, start);
|
||||
}
|
||||
_ => {
|
||||
move_to(&mut app, event, start + delta(messages, 30.));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Cancel {
|
||||
stamp: event.stamp(),
|
||||
token: event.token(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(app.document.can_redo() && !app.document.can_undo());
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
if mode == 0 {
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[SelectedTarget::ScenarioObject(ids[1])]
|
||||
);
|
||||
assert_eq!(app.selected, ids[1]);
|
||||
} else {
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(app.selected, primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_group_packets_and_generation_forgery_do_not_disturb_a_newer_live_gesture() {
|
||||
let (mut app, ids) = group(true, true);
|
||||
let (old, start, _) = begin(&mut app, Target::Message(ids[0]));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
},
|
||||
);
|
||||
let (current, current_start, _) = begin(&mut app, Target::Message(ids[1]));
|
||||
let original = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot();
|
||||
let epoch = app.canvas_epoch;
|
||||
let status = app.status.clone();
|
||||
let mut forged = current;
|
||||
if let Event::Begin { stamp, .. } = &mut forged {
|
||||
stamp.selection_generation = stamp.selection_generation.wrapping_add(1);
|
||||
}
|
||||
for event in [
|
||||
old,
|
||||
forged,
|
||||
Event::Move {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: start + Vector::new(0., 90.),
|
||||
},
|
||||
Event::Finish {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
screen: start + Vector::new(0., 90.),
|
||||
},
|
||||
Event::Cancel {
|
||||
stamp: old.stamp(),
|
||||
token: old.token(),
|
||||
},
|
||||
] {
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.multiselect_snapshot(), selection);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(current.token()));
|
||||
}
|
||||
finish(&mut app, current, current_start + Vector::new(0., 24.));
|
||||
assert!(app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_group_drag_refuses_whole_set_without_primary_only_change_or_replay() {
|
||||
let (mut app, lane, message) = setup();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let event = packet_for_app(&app, Target::Message(message));
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
let Event::Begin { press_screen, .. } = event else {
|
||||
unreachable!()
|
||||
};
|
||||
move_to(&mut app, event, press_screen + Vector::new(0., 30.));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
finish(&mut app, event, press_screen + Vector::new(0., 30.));
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
let status = app.status.clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_group_stationary_click_on_either_role_still_selects_only_that_member() {
|
||||
for lane_hit in [false, true] {
|
||||
let (mut app, lane, message) = setup();
|
||||
choose(&mut app, message, multiselect::Choice::Add);
|
||||
choose(&mut app, lane, multiselect::Choice::Add);
|
||||
let original = app.document.snapshot();
|
||||
let member = if lane_hit { lane } else { message };
|
||||
let (event, start, _) = begin(&mut app, target(!lane_hit, member));
|
||||
finish(&mut app, event, start);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[SelectedTarget::ScenarioObject(member)]
|
||||
);
|
||||
assert_eq!(app.selected, member);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_current_begin_consumes_its_lifetime_without_editing_or_replaying() {
|
||||
let (mut app, ids) = group(true, true);
|
||||
let mut event = packet_for_app(&app, Target::Message(ids[0]));
|
||||
if let Event::Begin { canvas_bounds, .. } = &mut event {
|
||||
canvas_bounds.width = 0.;
|
||||
}
|
||||
let original = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let epoch = app.canvas_epoch;
|
||||
send(&mut app, event);
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
let epoch = app.canvas_epoch;
|
||||
let status = app.status.clone();
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.status, status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_source_write_failure_is_atomic_and_preserves_selection_and_camera() {
|
||||
let (mut app, ids) = group(true, true);
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
app.sync();
|
||||
let original = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let primary = app.selected;
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Message(ids[1]));
|
||||
finish(&mut app, event, start + Vector::new(0., 24.));
|
||||
assert!(
|
||||
app.status.starts_with("Spacing was not applied:"),
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(app.selected, primary);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use behavior::scenario::{
|
||||
};
|
||||
use iced::{Point, Size, Vector};
|
||||
|
||||
pub(super) fn setup() -> (App, Id, Id) {
|
||||
fn setup() -> (App, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
@@ -88,21 +88,14 @@ fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture()
|
||||
"the current gesture remains usable"
|
||||
);
|
||||
}
|
||||
pub(in crate::app) fn packet_for_app(app: &App, target: Target) -> Event {
|
||||
let mut event = packet(
|
||||
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
let event = packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
target,
|
||||
);
|
||||
if let Event::Begin { stamp, .. } = &mut event {
|
||||
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
|
||||
}
|
||||
event
|
||||
}
|
||||
pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
let event = packet_for_app(app, target);
|
||||
let Event::Begin {
|
||||
press_screen,
|
||||
scale,
|
||||
@@ -115,10 +108,10 @@ pub(super) fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
(event, press_screen, scale)
|
||||
}
|
||||
pub(super) fn send(app: &mut App, event: Event) {
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
}
|
||||
pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Move {
|
||||
@@ -128,7 +121,7 @@ pub(super) fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
},
|
||||
);
|
||||
}
|
||||
pub(super) fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Finish {
|
||||
|
||||
@@ -116,7 +116,6 @@ impl App {
|
||||
}
|
||||
match event {
|
||||
Event::Selection(event) => self.multiselect_event(event),
|
||||
Event::ModelRemoval(event) => return self.saved_removal_event(*event),
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
|
||||
@@ -10,6 +10,11 @@ async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
||||
std::slice::from_ref(&input),
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(
|
||||
statuses,
|
||||
[iced::event::Status::Captured],
|
||||
"the mounted picker must capture Escape before App subscriptions"
|
||||
);
|
||||
crate::app::escape::tests::subscriptions(app, input, statuses[0], &mut messages);
|
||||
for message in messages.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
@@ -41,17 +46,11 @@ async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reope
|
||||
send(&mut app, Message::BehaviorDiagram(choice));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
assert_eq!(state_edit::unplaced(app.project(), diagram), vec![f.b]);
|
||||
let focus_id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
|
||||
let (_, canvas, _) = mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| id.as_ref() == Some(&focus_id))
|
||||
.unwrap();
|
||||
// The existing picker is immediately above the measured canvas, separated
|
||||
// by 8px. Its 12px text and 8px padding give a 31.6px row.
|
||||
let picker = iced::Point::new(canvas.center_x(), canvas.y - 24.);
|
||||
let option = iced::Point::new(picker.x, canvas.y + 8.);
|
||||
// Locate the stable picker slot itself: selection controls may sit between
|
||||
// it and the canvas. The single menu option opens below this slot.
|
||||
let bounds = mounted.geometry(&app).bounds("state-missing-picker");
|
||||
let picker = bounds.center();
|
||||
let option = iced::Point::new(picker.x, bounds.y + bounds.height * 1.5);
|
||||
let before = app.document.snapshot();
|
||||
let original = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
|
||||
@@ -1243,7 +1243,6 @@ struct Scene {
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
spacing_stamp: Option<scenario::spacing::Stamp>,
|
||||
selection: Option<(scenario::Context, crate::diagram_selection::Snapshot)>,
|
||||
coordinate_input: bool,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
@@ -1263,8 +1262,6 @@ struct SequenceSlot {
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct SequenceInput {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group_press: Option<ScenarioGroupPress>,
|
||||
context: Option<scenario::Context>,
|
||||
tool: scenario::CanvasTool,
|
||||
pointer: Option<Point>,
|
||||
@@ -1272,13 +1269,6 @@ struct SequenceInput {
|
||||
source: Option<(Id, scenario::Slot)>,
|
||||
press: Option<SequencePress>,
|
||||
}
|
||||
struct ScenarioGroupPress {
|
||||
context: scenario::Context,
|
||||
snapshot: crate::diagram_selection::Snapshot,
|
||||
target: crate::diagram_usage::Target,
|
||||
start: Point,
|
||||
moved: bool,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum SequencePress {
|
||||
Spacing {
|
||||
@@ -1319,48 +1309,11 @@ impl SequenceInput {
|
||||
cancel
|
||||
}
|
||||
fn cancel(&mut self) {
|
||||
self.group_press = None;
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Scene {
|
||||
fn selection_targets(&self) -> Vec<crate::diagram_usage::Target> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
ids.extend(
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter(|b| !b.title.is_empty())
|
||||
.map(|b| b.id),
|
||||
);
|
||||
ids.extend(self.lines.iter().map(|line| line.id));
|
||||
ids.extend(self.activations.iter().map(|segment| segment.call));
|
||||
ids.extend(self.render_lanes.keys().copied());
|
||||
ids.extend(self.lifecycle.selection_markers().map(|(id, _)| id));
|
||||
ids.into_iter()
|
||||
.map(crate::diagram_usage::Target::ScenarioObject)
|
||||
.collect()
|
||||
}
|
||||
fn is_selected(&self, id: Id) -> bool {
|
||||
self.selection
|
||||
.as_ref()
|
||||
.map_or(id == self.selected, |(_, snapshot)| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(id))
|
||||
})
|
||||
}
|
||||
fn is_group(&self) -> bool {
|
||||
self.selection
|
||||
.as_ref()
|
||||
.is_some_and(|(_, snapshot)| snapshot.targets().len() > 1)
|
||||
}
|
||||
fn selection_action(&self, intent: scenario::selection::Intent) -> Option<Action<Message>> {
|
||||
let (context, snapshot) = self.selection.as_ref()?;
|
||||
Some(
|
||||
Action::publish(scenario::selection::message(*context, snapshot, intent)).and_capture(),
|
||||
)
|
||||
}
|
||||
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
||||
id != owner
|
||||
&& (self.boxes.iter().any(|item| item.id == id)
|
||||
@@ -1387,7 +1340,6 @@ impl Scene {
|
||||
selected,
|
||||
interaction: None,
|
||||
spacing_stamp: None,
|
||||
selection: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
@@ -2030,24 +1982,7 @@ impl canvas::Program<Message> for Scene {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use crate::{app::multiselect::Choice, diagram_usage::Target};
|
||||
use scenario::selection::Intent;
|
||||
use scenario::{CanvasEvent, CanvasTool};
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
}
|
||||
if state.group_press.as_ref().is_some_and(|press| {
|
||||
self.selection.as_ref().is_none_or(|(context, snapshot)| {
|
||||
*context != press.context || *snapshot != press.snapshot
|
||||
})
|
||||
}) {
|
||||
state.cancel();
|
||||
}
|
||||
|
||||
let (context, tool, editable) = self
|
||||
.interaction
|
||||
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
|
||||
@@ -2072,14 +2007,10 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if (!editable || !self.coordinate_input)
|
||||
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
|
||||
&& let Some(cancelled) = state.cancel_spacing()
|
||||
{
|
||||
return Some(cancelled);
|
||||
}
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
@@ -2106,9 +2037,6 @@ impl canvas::Program<Message> for Scene {
|
||||
let cursor = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
state.pointer = Some(*position);
|
||||
if let Some(press) = &mut state.group_press {
|
||||
press.moved |= position.distance(press.start) >= 3.;
|
||||
}
|
||||
if let Some(press) = &mut state.press {
|
||||
match press {
|
||||
SequencePress::Message { start, moved, .. }
|
||||
@@ -2144,22 +2072,9 @@ impl canvas::Program<Message> for Scene {
|
||||
&& tool == CanvasTool::Select
|
||||
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
&& state.press.is_none()
|
||||
&& state.group_press.is_none()
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.is_group() {
|
||||
let (_, snapshot) = self.selection.as_ref()?;
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioRemoval(Box::new(
|
||||
crate::app::scenario_removal::Event::Begin {
|
||||
context,
|
||||
snapshot: Box::new(snapshot.clone()),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if self.removal_target(context.scenario, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
@@ -2174,10 +2089,7 @@ impl canvas::Program<Message> for Scene {
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if state.source.is_some()
|
||||
|| state.press.is_some()
|
||||
|| state.group_press.is_some() =>
|
||||
{
|
||||
}) if state.source.is_some() || state.press.is_some() => {
|
||||
return state
|
||||
.cancel_spacing()
|
||||
.or_else(|| Some(Action::request_redraw().and_capture()));
|
||||
@@ -2191,18 +2103,6 @@ impl canvas::Program<Message> for Scene {
|
||||
)
|
||||
});
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if editable => {
|
||||
let (context, _) = self.selection.as_ref()?;
|
||||
let hit = self.hit_item(world?)?;
|
||||
let mut request = crate::model_menu::Request::object(hit.id, cursor.position()?);
|
||||
request.scenario = Some(Box::new(*context));
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(hit.id)));
|
||||
state.cancel();
|
||||
Some(
|
||||
Action::publish(Message::Menu(crate::model_menu::Event::Open(request)))
|
||||
.and_capture(),
|
||||
)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
||||
if !visible {
|
||||
return None;
|
||||
@@ -2212,83 +2112,6 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
if let Some((selection_context, snapshot)) = &self.selection {
|
||||
let hit = self.hit_item(point).or_else(|| {
|
||||
(!self.is_group()
|
||||
&& self.is_selected(self.selected)
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
))
|
||||
.then(|| {
|
||||
self.reorder_handle()
|
||||
.filter(|handle| handle.contains(point))
|
||||
})
|
||||
.flatten()
|
||||
.map(|_| SceneHit {
|
||||
id: self.selected,
|
||||
role: SceneHitRole::Box,
|
||||
})
|
||||
});
|
||||
if tool != CanvasTool::Select && self.is_group() {
|
||||
return self.selection_action(Intent::Refuse(
|
||||
"Select one occurrence before using an authoring tool".into(),
|
||||
));
|
||||
}
|
||||
if tool == CanvasTool::Select {
|
||||
if let Some(hit) = hit {
|
||||
let target = Target::ScenarioObject(hit.id);
|
||||
if state.modifiers.shift() {
|
||||
state.cancel();
|
||||
return self
|
||||
.selection_action(Intent::Choose(target, Choice::Toggle));
|
||||
}
|
||||
let group_spacing_hit = self.coordinate_input
|
||||
&& self.spacing_stamp.is_some()
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Saved { .. }
|
||||
)
|
||||
&& matches!(
|
||||
hit.role,
|
||||
SceneHitRole::LifelineHeader
|
||||
| SceneHitRole::MessageBody
|
||||
| SceneHitRole::MessageLabel
|
||||
);
|
||||
if self.is_group() && self.is_selected(hit.id) && !group_spacing_hit
|
||||
{
|
||||
state.group_press = Some(ScenarioGroupPress {
|
||||
context: *selection_context,
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: cursor.position()?,
|
||||
moved: false,
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
// Preserve the existing first-press singleton
|
||||
// spacing/reorder gesture. Replacing a displayed
|
||||
// group is an explicit selection change first.
|
||||
if (self.is_group() && !self.is_selected(hit.id))
|
||||
|| !self.coordinate_input
|
||||
{
|
||||
return self
|
||||
.selection_action(Intent::Choose(target, Choice::Replace));
|
||||
}
|
||||
} else {
|
||||
state.cancel();
|
||||
if state.modifiers.shift() {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
// Background clearing shares this press with camera panning.
|
||||
return Some(Action::publish(scenario::selection::message(
|
||||
*selection_context,
|
||||
snapshot,
|
||||
Intent::Clear,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !self.coordinate_input {
|
||||
return self.hit(point).map(|id| {
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
@@ -2391,27 +2214,6 @@ impl canvas::Program<Message> for Scene {
|
||||
.map(|id| Action::publish(Message::Select(id)).and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
if let Some(press) = state.group_press.take() {
|
||||
if world.is_none() {
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
let intent = if press.moved {
|
||||
Intent::Refuse(
|
||||
"This selection cannot be moved here; select one occurrence for its individual actions"
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
Intent::Collapse(press.target)
|
||||
};
|
||||
return Some(
|
||||
Action::publish(scenario::selection::message(
|
||||
press.context,
|
||||
&press.snapshot,
|
||||
intent,
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
@@ -2563,7 +2365,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}),
|
||||
Stroke::default()
|
||||
.with_color(if self.is_selected(guide.id) {
|
||||
.with_color(if guide.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2589,7 +2391,7 @@ impl canvas::Program<Message> for Scene {
|
||||
f.stroke(
|
||||
&path,
|
||||
Stroke::default()
|
||||
.with_color(if self.is_selected(activation.call) {
|
||||
.with_color(if activation.call == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2606,7 +2408,7 @@ impl canvas::Program<Message> for Scene {
|
||||
b.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
);
|
||||
let color = if self.is_selected(b.id) {
|
||||
let color = if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::BOUNDARY
|
||||
@@ -2628,7 +2430,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
for l in self.lines.iter().filter(|line| !line.label.is_empty()) {
|
||||
let color = if self.is_selected(l.id) {
|
||||
let color = if l.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::MUTED
|
||||
@@ -2684,7 +2486,7 @@ impl canvas::Program<Message> for Scene {
|
||||
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
|
||||
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
|
||||
size: 13.into(),
|
||||
color: if self.is_selected(b.id) {
|
||||
color: if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::INK
|
||||
@@ -2714,19 +2516,6 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
if self.selection.is_some() {
|
||||
// Lifecycle glyphs use their exact painted hit identity too.
|
||||
for (id, point) in self.lifecycle.selection_markers() {
|
||||
if self.is_selected(id) {
|
||||
f.stroke(
|
||||
&Path::circle(point, 12.),
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(if id == self.selected { 3. } else { 1.5 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable
|
||||
&& self.coordinate_input
|
||||
@@ -2753,8 +2542,6 @@ impl canvas::Program<Message> for Scene {
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
)
|
||||
&& !self.is_group()
|
||||
&& self.is_selected(self.selected)
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
||||
|
||||
@@ -6,13 +6,6 @@ use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_removal;
|
||||
pub(crate) mod group_reorder;
|
||||
pub(crate) mod reorder_session;
|
||||
#[cfg(test)]
|
||||
mod reorder_slot_experiments;
|
||||
pub(crate) mod reorder_view;
|
||||
pub(crate) mod selection;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
@@ -150,7 +143,6 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Selection(Box<selection::Event>),
|
||||
Spacing(spacing::Event),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
@@ -194,7 +186,6 @@ impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Spacing(event) => event.stamp().context,
|
||||
Self::Selection(event) => event.context,
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
@@ -1262,21 +1253,26 @@ pub(crate) fn reorder(
|
||||
occurrence: Id,
|
||||
slot: Slot,
|
||||
) -> Result<Command, String> {
|
||||
let prepared = group_reorder::Prepared::new(
|
||||
std::sync::Arc::new(p.clone()),
|
||||
owner,
|
||||
&[crate::diagram_usage::Target::ScenarioObject(occurrence)],
|
||||
)?;
|
||||
match prepared.plan(slot)? {
|
||||
group_reorder::Plan::Change(command) => Ok(*command),
|
||||
group_reorder::Plan::Noop => Ok(Command::SetSteps(
|
||||
slot.parent,
|
||||
p.elements[&slot.parent]
|
||||
.steps()
|
||||
.expect("admitted sequence")
|
||||
.clone(),
|
||||
)),
|
||||
let to = slot_index(p, owner, slot)?;
|
||||
if p.elements.get(&occurrence).and_then(|e| e.parent) != Some(slot.parent) {
|
||||
return Err(
|
||||
"Reorder inside the occurrence's current sequence; dragging never changes its operand."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut steps = p.elements[&slot.parent]
|
||||
.steps()
|
||||
.expect("slot validated")
|
||||
.clone();
|
||||
let from = steps
|
||||
.iter()
|
||||
.position(|id| *id == occurrence)
|
||||
.ok_or("This occurrence is not in the sequence.")?;
|
||||
steps.remove(from);
|
||||
steps.insert(to - usize::from(from < to), occurrence);
|
||||
let command = Command::SetSteps(slot.parent, steps);
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|error| error.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
fn compatible_binding(p: &Project, referenced: Id, local: Id) -> bool {
|
||||
represented(p, referenced)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
//! Whole-set local Scenario removal. UI visibility and input lifetime admission
|
||||
//! belong to the caller; this planner validates structural identity and semantics.
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Command, Id, Kind, Layer, OccurrenceBody, Project, SharedBody, execute_batch};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub(crate) command: Command,
|
||||
/// Actual deleted identities, including owned descendants and dependents.
|
||||
pub(crate) removed: BTreeSet<Id>,
|
||||
/// Surviving records changed by reference clearing, sequence edits or view
|
||||
/// pruning. Embedded surviving placements are included alongside diagrams.
|
||||
pub(crate) changed: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. }
|
||||
if matches!(
|
||||
body,
|
||||
OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::State { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Fragment { .. }
|
||||
) =>
|
||||
{
|
||||
Some(body)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the actual ordered/operand lists, not only parent pointers. Check the
|
||||
/// complete local tree so malformed siblings cannot become a permissive subset.
|
||||
fn local_occurrences(project: &Project, owner: Id, layer: Layer) -> Result<BTreeSet<Id>, String> {
|
||||
let mut reachable = BTreeSet::new();
|
||||
let mut sequences = vec![owner];
|
||||
while let Some(parent) = sequences.pop() {
|
||||
let steps = project.elements[&parent]
|
||||
.steps()
|
||||
.ok_or("The Scenario sequence is no longer available.")?;
|
||||
for &id in steps {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.ok_or("A Scenario step is missing.")?;
|
||||
let body =
|
||||
step_body(project, id).ok_or("The Scenario contains an unsupported step.")?;
|
||||
if element.parent != Some(parent) || element.layer() != layer || !reachable.insert(id) {
|
||||
return Err("The Scenario lists must contain unique local occurrences of their actual parents.".into());
|
||||
}
|
||||
if let OccurrenceBody::Fragment { branches, .. } = body {
|
||||
for &branch in branches {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&branch)
|
||||
.ok_or("A Scenario operand is missing.")?;
|
||||
if element.parent != Some(id)
|
||||
|| element.layer() != layer
|
||||
|| !matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
|| !reachable.insert(branch)
|
||||
{
|
||||
return Err("The Scenario fragment must list each actual local operand exactly once.".into());
|
||||
}
|
||||
sequences.push(branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(reachable)
|
||||
}
|
||||
|
||||
fn changed_records<T: PartialEq>(
|
||||
before: &BTreeMap<Id, T>,
|
||||
after: &BTreeMap<Id, T>,
|
||||
changed: &mut BTreeSet<Id>,
|
||||
) {
|
||||
changed.extend(
|
||||
before
|
||||
.iter()
|
||||
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let owner_element = project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|element| element.kind() == Kind::Scenario)
|
||||
.ok_or("This Scenario no longer exists.")?;
|
||||
let mut selected = BTreeSet::new();
|
||||
for target in targets {
|
||||
let Target::ScenarioObject(id) = target else {
|
||||
return Err("Remove only local Scenario participants and occurrences together.".into());
|
||||
};
|
||||
if !selected.insert(*id) {
|
||||
return Err("Every selected Scenario subject must be unique.".into());
|
||||
}
|
||||
}
|
||||
if selected.is_empty() {
|
||||
return Err("Select at least one Scenario participant or occurrence to remove.".into());
|
||||
}
|
||||
oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?;
|
||||
let reachable = local_occurrences(project, owner, owner_element.layer())?;
|
||||
for &id in &selected {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.ok_or("A selected Scenario subject no longer exists.")?;
|
||||
let local = if element.kind() == Kind::Lifeline {
|
||||
element.parent == Some(owner) && element.layer() == owner_element.layer()
|
||||
} else {
|
||||
reachable.contains(&id)
|
||||
};
|
||||
if !local {
|
||||
return Err("Every selected subject must be a local participant or a listed occurrence/operand in this Scenario.".into());
|
||||
}
|
||||
}
|
||||
// Admission above includes every original member. An admitted selected
|
||||
// descendant is covered by its selected ancestor's core cascade, not dropped
|
||||
// as an ineligible target. Sending both Delete commands would delete it twice.
|
||||
let roots = selected.iter().copied().filter(|id| {
|
||||
let mut parent = project.elements[id].parent;
|
||||
while let Some(id) = parent {
|
||||
if selected.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
parent = project.elements.get(&id).and_then(|element| element.parent);
|
||||
}
|
||||
true
|
||||
});
|
||||
let command = Command::Batch(roots.map(Command::Delete).collect());
|
||||
// One candidate and one final semantic validation, with no intermediate
|
||||
// Document transactions or singleton validation that can reject a valid union.
|
||||
let candidate = execute_batch(project, std::slice::from_ref(&command))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let retained: BTreeSet<_> = candidate
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
let removed: BTreeSet<_> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.filter(|id| !retained.contains(id))
|
||||
.collect();
|
||||
if !selected.is_subset(&removed) {
|
||||
return Err("The complete selected set could not be removed.".into());
|
||||
}
|
||||
let mut changed = BTreeSet::new();
|
||||
changed_records(&project.elements, &candidate.elements, &mut changed);
|
||||
changed_records(&project.relations, &candidate.relations, &mut changed);
|
||||
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
|
||||
changed_records(
|
||||
&project.artifact_links,
|
||||
&candidate.artifact_links,
|
||||
&mut changed,
|
||||
);
|
||||
changed_records(&project.traces, &candidate.traces, &mut changed);
|
||||
for (&id, diagram) in &project.diagrams {
|
||||
if let Some(after) = candidate.diagrams.get(&id) {
|
||||
changed_records(&diagram.nodes, &after.nodes, &mut changed);
|
||||
}
|
||||
}
|
||||
Ok(Plan {
|
||||
command,
|
||||
removed,
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,824 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{behavior::topology::occurrence, document::Document};
|
||||
use oa_core::{
|
||||
ArtifactLink, ArtifactRelation, Diagram, DiagramLayout, FragmentOperator, LifelineBinding,
|
||||
MessageKind, OccurrenceEndpoint, Position, ProjectionKind, ScenarioLanePosition,
|
||||
ScenarioLayout, ScenarioMessagePosition, Trace, TraceKind, language,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
work: [Id; 2],
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let element = occurrence(project, parent, name, body);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn body(project: &mut Project, id: Id) -> &mut OccurrenceBody {
|
||||
let SharedBody::Occurrence { body, .. } =
|
||||
project.elements.get_mut(&id).unwrap().shared_mut().unwrap()
|
||||
else {
|
||||
panic!("occurrence")
|
||||
};
|
||||
body
|
||||
}
|
||||
|
||||
fn action(project: &mut Project, parent: Id, name: &str) -> Id {
|
||||
add(
|
||||
project,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn operand(project: &mut Project, fragment: Id, name: &str) -> Id {
|
||||
let element = occurrence(
|
||||
project,
|
||||
fragment,
|
||||
name,
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
let OccurrenceBody::Fragment { branches, .. } = body(project, fragment) else {
|
||||
panic!()
|
||||
};
|
||||
branches.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, layer, "Conversation", None)
|
||||
.unwrap();
|
||||
let work = ["Source work", "Target work"].map(|name| {
|
||||
project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
layer,
|
||||
name,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
let lanes = ["Left", "Right"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, layer, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
for (lane, work) in lanes.into_iter().zip(work) {
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&lane)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(work),
|
||||
};
|
||||
}
|
||||
let messages = ["First", "Second"].map(|name| {
|
||||
add(
|
||||
&mut project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
)
|
||||
});
|
||||
let views = ["Saved A", "Saved B"].map(|name| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: project.fresh_alias(name, "view", id),
|
||||
name: name.into(),
|
||||
description: "Keep authored metadata".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 140.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 540.,
|
||||
},
|
||||
],
|
||||
messages: messages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, &occurrence)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y: 220. + index as f32 * 160.,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
oa_core::session::validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
work,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(ids: &[Id]) -> Vec<Target> {
|
||||
ids.iter().copied().map(Target::ScenarioObject).collect()
|
||||
}
|
||||
|
||||
fn layout(project: &mut Project, diagram: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&diagram).unwrap().layout
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn exact_transaction(project: &Project, plan: Plan, expected: &Project) {
|
||||
assert!(matches!(plan.command, Command::Batch(_)));
|
||||
assert_eq!(
|
||||
&execute_batch(project, std::slice::from_ref(&plan.command)).unwrap(),
|
||||
expected
|
||||
);
|
||||
let mut document = Document::from_project(project.clone(), Default::default()).unwrap();
|
||||
let original = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project(), expected);
|
||||
let changed = document.bundle().clone();
|
||||
assert_eq!(
|
||||
language::parse(&language::format(expected)).unwrap(),
|
||||
*expected
|
||||
);
|
||||
assert_eq!(
|
||||
Document::from_bundle(changed.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
expected
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), project);
|
||||
assert_eq!(document.bundle(), &original);
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), expected);
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
assert!(!document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_messages_in_both_layers_preserve_definitions_and_exact_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
let baseline = f.project.clone();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&f.messages)).unwrap();
|
||||
let reversed = prepare(
|
||||
&f.project,
|
||||
f.owner,
|
||||
&selected(&[f.messages[1], f.messages[0]]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(plan.command, reversed.command);
|
||||
assert_eq!(plan.removed, f.messages.into_iter().collect());
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, f.views[0], f.views[1]].into_iter().collect()
|
||||
);
|
||||
let mut expected = f.project.clone();
|
||||
for id in f.messages {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.clear();
|
||||
}
|
||||
for id in f.work {
|
||||
assert_eq!(expected.elements[&id], f.project.elements[&id]);
|
||||
}
|
||||
assert_eq!(f.project, baseline, "preparation is pure");
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_participant_and_message_union_clears_surviving_references_and_prunes_both_views() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let local_action = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Local action",
|
||||
OccurrenceBody::Action {
|
||||
action: Some(f.work[0]),
|
||||
lifeline: Some(f.lanes[0]),
|
||||
},
|
||||
);
|
||||
let observation = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Observation",
|
||||
OccurrenceBody::State {
|
||||
state: None,
|
||||
lifeline: Some(f.lanes[0]),
|
||||
},
|
||||
);
|
||||
let definition = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Reused definition", None)
|
||||
.unwrap();
|
||||
let remote = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Remote participant",
|
||||
Some(definition),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&remote)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(f.work[0]),
|
||||
};
|
||||
let use_id = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Use definition",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![LifelineBinding {
|
||||
referenced: remote,
|
||||
local: f.lanes[0],
|
||||
}],
|
||||
},
|
||||
);
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[f.lanes[0], f.messages[0]])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.elements.remove(&f.lanes[0]);
|
||||
expected.elements.remove(&f.messages[0]);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1], local_action, observation, use_id];
|
||||
let OccurrenceBody::Exchange { sender, .. } = body(&mut expected, f.messages[1]) else {
|
||||
panic!()
|
||||
};
|
||||
*sender = None;
|
||||
let OccurrenceBody::Action { lifeline, .. } = body(&mut expected, local_action) else {
|
||||
panic!()
|
||||
};
|
||||
*lifeline = None;
|
||||
let OccurrenceBody::State { lifeline, .. } = body(&mut expected, observation) else {
|
||||
panic!()
|
||||
};
|
||||
*lifeline = None;
|
||||
let OccurrenceBody::ScenarioUse { bindings, .. } = body(&mut expected, use_id) else {
|
||||
panic!()
|
||||
};
|
||||
bindings.clear();
|
||||
for view in f.views {
|
||||
let rows = layout(&mut expected, view);
|
||||
rows.lanes.remove(0);
|
||||
rows.messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[f.lanes[0], f.messages[0]].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[
|
||||
f.owner,
|
||||
f.messages[1],
|
||||
local_action,
|
||||
observation,
|
||||
use_id,
|
||||
f.views[0],
|
||||
f.views[1]
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert_eq!(
|
||||
expected.elements[&definition],
|
||||
f.project.elements[&definition]
|
||||
);
|
||||
assert_eq!(expected.elements[&remote], f.project.elements[&remote]);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_removal_keeps_reply_as_warning_admitted_draft_and_reports_its_changed_identity() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let OccurrenceBody::Exchange { message_kind, .. } = body(&mut f.project, f.messages[0]) else {
|
||||
panic!()
|
||||
};
|
||||
*message_kind = MessageKind::Call;
|
||||
let OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
} = body(&mut f.project, f.messages[1])
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*sender = Some(f.lanes[1]);
|
||||
*receiver = Some(f.lanes[0]);
|
||||
*message_kind = MessageKind::Reply;
|
||||
*reply_to = Some(f.messages[0]);
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0], f.lanes[1]])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.elements.remove(&f.messages[0]);
|
||||
expected.elements.remove(&f.lanes[1]);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1]];
|
||||
let OccurrenceBody::Exchange {
|
||||
sender, reply_to, ..
|
||||
} = body(&mut expected, f.messages[1])
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*sender = None;
|
||||
*reply_to = None;
|
||||
for view in f.views {
|
||||
let rows = layout(&mut expected, view);
|
||||
rows.lanes.remove(1);
|
||||
rows.messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[f.messages[0], f.lanes[1]].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, f.messages[1], f.views[0], f.views[1]]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert!(
|
||||
oa_core::validation::validate(&expected)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "missing_reply_call"
|
||||
&& issue.severity == oa_core::validation::Severity::Warning)
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
fn nested(f: &mut Fixture) -> (Id, [Id; 2], [Id; 2]) {
|
||||
let fragment = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Alternatives",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branches = [
|
||||
operand(&mut f.project, fragment, "Left operand"),
|
||||
operand(&mut f.project, fragment, "Right operand"),
|
||||
];
|
||||
let children = [
|
||||
action(&mut f.project, branches[0], "Left action"),
|
||||
action(&mut f.project, branches[1], "Right action"),
|
||||
];
|
||||
(fragment, branches, children)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_ancestor_covers_all_descendants_after_admission_and_executes_once() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
assert!(
|
||||
execute_batch(
|
||||
&f.project,
|
||||
&[Command::Delete(fragment), Command::Delete(children[0])]
|
||||
)
|
||||
.is_err(),
|
||||
"naive cascading singleton deletes are order-dependent"
|
||||
);
|
||||
let members = [children[0], f.messages[1], fragment, branches[0]];
|
||||
let plan = prepare(&f.project, f.owner, &selected(&members)).unwrap();
|
||||
let roots: BTreeSet<_> = [fragment, f.messages[1]].into_iter().collect();
|
||||
assert_eq!(
|
||||
plan.command,
|
||||
Command::Batch(roots.into_iter().map(Command::Delete).collect())
|
||||
);
|
||||
let removed = [
|
||||
fragment,
|
||||
branches[0],
|
||||
branches[1],
|
||||
children[0],
|
||||
children[1],
|
||||
f.messages[1],
|
||||
];
|
||||
assert_eq!(plan.removed, removed.into_iter().collect());
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, f.views[0], f.views[1]].into_iter().collect()
|
||||
);
|
||||
let mut expected = f.project.clone();
|
||||
for id in removed {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[0]];
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.remove(1);
|
||||
}
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_operands_can_be_removed_together_without_a_reorder_parent_restriction() {
|
||||
let mut f = fixture(Layer::Sa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
let plan = prepare(
|
||||
&f.project,
|
||||
f.owner,
|
||||
&selected(&[children[1], f.messages[0], children[0]]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
for id in [children[0], children[1], f.messages[0]] {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
for branch in branches {
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
}
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1], fragment];
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, branches[0], branches[1], f.views[0], f.views[1]]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_use_are_local_removal_targets_and_reused_definition_survives() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
let definition = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
|
||||
.unwrap();
|
||||
let use_id = add(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
"Use",
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[branches[0], use_id])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
for id in [branches[0], children[0], use_id] {
|
||||
expected.elements.remove(&id);
|
||||
}
|
||||
let OccurrenceBody::Fragment {
|
||||
branches: remaining,
|
||||
..
|
||||
} = body(&mut expected, fragment)
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*remaining = vec![branches[1]];
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[0], f.messages[1], fragment];
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[branches[0], children[0], use_id].into_iter().collect()
|
||||
);
|
||||
assert_eq!(plan.changed, [f.owner, fragment].into_iter().collect());
|
||||
assert_eq!(
|
||||
expected.elements[&definition],
|
||||
f.project.elements[&definition]
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impact_includes_placements_artifacts_traces_and_changed_surviving_constraints() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let overview = f.project.ensure_diagram(Layer::Oa);
|
||||
let node = f
|
||||
.project
|
||||
.place(overview, f.messages[0], Position { x: 100., y: 100. });
|
||||
let other_node = f
|
||||
.project
|
||||
.place(overview, f.messages[1], Position { x: 400., y: 100. });
|
||||
let counterpart = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
"Opposite-layer counterpart",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let trace = Id::new_v4();
|
||||
f.project.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "manual_trace".into(),
|
||||
name: "Manual trace".into(),
|
||||
description: String::new(),
|
||||
oa: f.messages[0],
|
||||
sa: counterpart,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let artifact = Id::new_v4();
|
||||
f.project.artifact_links.insert(
|
||||
artifact,
|
||||
ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "evidence".into(),
|
||||
name: "Evidence".into(),
|
||||
description: String::new(),
|
||||
owner: f.messages[0],
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "evidence.txt".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let constraint = f
|
||||
.project
|
||||
.add_element(Kind::Constraint, Layer::Oa, "Constraint", None)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Constraint {
|
||||
expression: "review".into(),
|
||||
constrained: f.messages.to_vec(),
|
||||
};
|
||||
oa_core::session::validate_candidate(&f.project).unwrap();
|
||||
let plan = prepare(&f.project, f.owner, &selected(&[f.messages[0]])).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.elements.remove(&f.messages[0]);
|
||||
expected.traces.remove(&trace);
|
||||
expected.artifact_links.remove(&artifact);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&overview)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&node);
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.messages[1]];
|
||||
let SharedBody::Constraint { constrained, .. } = expected
|
||||
.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*constrained = vec![f.messages[1]];
|
||||
for view in f.views {
|
||||
layout(&mut expected, view).messages.remove(0);
|
||||
}
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
[f.messages[0], node, trace, artifact].into_iter().collect()
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
[f.owner, constraint, overview, f.views[0], f.views[1]]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
assert_eq!(
|
||||
expected.elements[&counterpart],
|
||||
f.project.elements[&counterpart]
|
||||
);
|
||||
assert_eq!(
|
||||
expected.diagrams[&overview].nodes[&other_node],
|
||||
f.project.diagrams[&overview].nodes[&other_node]
|
||||
);
|
||||
exact_transaction(&f.project, plan, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_typed_selection_refuses_missing_foreign_definitions_and_duplicates_before_collapse() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, _, children) = nested(&mut f);
|
||||
let other = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign", Some(other))
|
||||
.unwrap();
|
||||
for targets in [
|
||||
vec![],
|
||||
vec![Target::Whole],
|
||||
selected(&[f.messages[0], f.messages[0]]),
|
||||
selected(&[f.messages[0], Id::nil()]),
|
||||
selected(&[f.messages[0], f.owner]),
|
||||
selected(&[f.messages[0], f.work[0]]),
|
||||
selected(&[f.messages[0], foreign]),
|
||||
vec![
|
||||
Target::ScenarioObject(f.messages[0]),
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
occurrence: f.messages[1],
|
||||
references: vec![],
|
||||
}),
|
||||
],
|
||||
selected(&[fragment, children[0], children[0]]),
|
||||
selected(&[fragment, Id::nil()]),
|
||||
] {
|
||||
let original = f.project.clone();
|
||||
assert!(prepare(&f.project, f.owner, &targets).is_err());
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
assert!(prepare(&f.project, f.work[0], &selected(&f.messages)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_list_reachability_and_complete_siblings_are_required() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let (fragment, branches, children) = nested(&mut f);
|
||||
let mut cases = Vec::new();
|
||||
let mut missing_branch = f.project.clone();
|
||||
let OccurrenceBody::Fragment {
|
||||
branches: listed, ..
|
||||
} = body(&mut missing_branch, fragment)
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
listed.clear();
|
||||
cases.push(missing_branch);
|
||||
let mut unlisted_child = f.project.clone();
|
||||
unlisted_child
|
||||
.elements
|
||||
.get_mut(&branches[0])
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.clear();
|
||||
cases.push(unlisted_child);
|
||||
let mut dangling_sibling = f.project.clone();
|
||||
dangling_sibling
|
||||
.elements
|
||||
.get_mut(&branches[1])
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(Id::new_v4());
|
||||
cases.push(dangling_sibling);
|
||||
let mut duplicate_sibling = f.project.clone();
|
||||
duplicate_sibling
|
||||
.elements
|
||||
.get_mut(&branches[1])
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(children[1]);
|
||||
cases.push(duplicate_sibling);
|
||||
let mut wrong_parent = f.project.clone();
|
||||
wrong_parent.elements.get_mut(&children[0]).unwrap().parent = Some(branches[1]);
|
||||
cases.push(wrong_parent);
|
||||
for project in cases {
|
||||
assert!(
|
||||
prepare(&project, f.owner, &selected(&[fragment, children[0]])).is_err(),
|
||||
"ancestor coverage never conceals an invalid original member/tree"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_baseline_cannot_be_silently_repaired_and_refusal_preserves_existing_redo() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let mut invalid = f.project.clone();
|
||||
let duplicate_alias = invalid.elements[&f.owner].alias.clone();
|
||||
invalid.elements.get_mut(&f.messages[0]).unwrap().alias = duplicate_alias;
|
||||
assert!(oa_core::session::validate_candidate(&invalid).is_err());
|
||||
// The final deletion could repair this error, but the planner must admit a
|
||||
// valid original baseline rather than quietly using deletion as repair.
|
||||
assert!(execute_batch(&invalid, &[Command::Delete(f.messages[0])]).is_ok());
|
||||
assert!(prepare(&invalid, f.owner, &selected(&[f.messages[0]])).is_err());
|
||||
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
document
|
||||
.execute(Command::Rename(f.messages[1], "Prior edit".into()))
|
||||
.unwrap();
|
||||
assert!(document.undo());
|
||||
let before = document.bundle().clone();
|
||||
let revision = document.revision();
|
||||
assert!(
|
||||
prepare(
|
||||
document.project(),
|
||||
f.owner,
|
||||
&selected(&[f.messages[0], f.work[0]])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert!(document.can_redo());
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
//! Frozen, all-or-nothing reorder of one immediate Scenario sequence.
|
||||
use super::Slot;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{Command, Id, Kind, OccurrenceBody, Project, SharedBody, execute_batch};
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum Plan {
|
||||
Noop,
|
||||
Change(Box<Command>),
|
||||
}
|
||||
|
||||
pub(crate) struct Prepared {
|
||||
project: Arc<Project>,
|
||||
parent: Id,
|
||||
original: Vec<Id>,
|
||||
selected: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn step_body(project: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match project.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. }
|
||||
if matches!(
|
||||
body,
|
||||
OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::State { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Fragment { .. }
|
||||
) =>
|
||||
{
|
||||
Some(body)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// Follow the same structural edges that make rows visible; parent pointers alone
|
||||
// would admit children of an operand omitted from its fragment's branch list.
|
||||
fn reachable_parent(project: &Project, owner: Id, wanted: Id) -> bool {
|
||||
let mut pending = vec![owner];
|
||||
let mut visited = BTreeSet::new();
|
||||
while let Some(parent) = pending.pop() {
|
||||
if !visited.insert(parent) {
|
||||
continue;
|
||||
}
|
||||
if parent == wanted {
|
||||
return true;
|
||||
}
|
||||
let Some(steps) = project.elements.get(&parent).and_then(|e| e.steps()) else {
|
||||
continue;
|
||||
};
|
||||
for id in steps {
|
||||
if project.elements.get(id).and_then(|e| e.parent) != Some(parent) {
|
||||
continue;
|
||||
}
|
||||
if let Some(OccurrenceBody::Fragment { branches, .. }) = step_body(project, *id) {
|
||||
pending.extend(branches.iter().copied().filter(|branch| {
|
||||
project.elements.get(branch).is_some_and(|element| {
|
||||
element.parent == Some(*id)
|
||||
&& matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
pub(crate) fn new(
|
||||
project: Arc<Project>,
|
||||
owner: Id,
|
||||
targets: &[Target],
|
||||
) -> Result<Self, String> {
|
||||
if project.elements.get(&owner).map(|e| e.kind()) != Some(Kind::Scenario) {
|
||||
return Err("This scenario no longer exists.".into());
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut parent = None;
|
||||
for target in targets {
|
||||
let Target::ScenarioObject(id) = target else {
|
||||
return Err("Select only ordered occurrences in this scenario.".into());
|
||||
};
|
||||
if !selected.insert(*id) || step_body(&project, *id).is_none() {
|
||||
return Err(
|
||||
"Every selected subject must be a unique ordered scenario occurrence.".into(),
|
||||
);
|
||||
}
|
||||
let actual = project.elements[id]
|
||||
.parent
|
||||
.ok_or("The occurrence has no sequence.")?;
|
||||
if parent.is_some_and(|parent| parent != actual) {
|
||||
return Err(
|
||||
"Reorder inside one immediate sequence; dragging never changes operands."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
parent = Some(actual);
|
||||
}
|
||||
let parent = parent.ok_or("Select at least one occurrence.")?;
|
||||
if !reachable_parent(&project, owner, parent) {
|
||||
return Err("The selected sequence is not reachable in this scenario.".into());
|
||||
}
|
||||
let original = project.elements[&parent]
|
||||
.steps()
|
||||
.ok_or("The parent is not a sequence.")?
|
||||
.clone();
|
||||
let mut listed = BTreeSet::new();
|
||||
for id in &original {
|
||||
if !listed.insert(*id)
|
||||
|| step_body(&project, *id).is_none()
|
||||
|| project.elements[id].parent != Some(parent)
|
||||
{
|
||||
return Err("The complete sequence must contain unique, existing occurrences of this parent.".into());
|
||||
}
|
||||
}
|
||||
if !selected.is_subset(&listed) {
|
||||
return Err("A selected occurrence is not in this sequence.".into());
|
||||
}
|
||||
// Cache a validated immutable baseline, including descendants and other
|
||||
// sequences. Advisory authoring diagnostics deliberately remain allowed.
|
||||
oa_core::session::validate_candidate(&project).map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
project,
|
||||
parent,
|
||||
original,
|
||||
selected,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn plan(&self, slot: Slot) -> Result<Plan, String> {
|
||||
if slot.parent != self.parent {
|
||||
return Err("Reorder inside the occurrence's current sequence; dragging never changes its operand.".into());
|
||||
}
|
||||
if let Some(anchor) = slot.before {
|
||||
if !self.original.contains(&anchor) {
|
||||
return Err("The insertion slot changed. Choose its position again.".into());
|
||||
}
|
||||
if self.selected.contains(&anchor) {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
}
|
||||
let mut steps: Vec<_> = self
|
||||
.original
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !self.selected.contains(id))
|
||||
.collect();
|
||||
let at = slot.before.map_or(steps.len(), |anchor| {
|
||||
steps
|
||||
.iter()
|
||||
.position(|id| *id == anchor)
|
||||
.expect("admitted anchor")
|
||||
});
|
||||
steps.splice(
|
||||
at..at,
|
||||
self.original
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| self.selected.contains(id)),
|
||||
);
|
||||
if steps == self.original {
|
||||
return Ok(Plan::Noop);
|
||||
}
|
||||
let command = Command::SetSteps(self.parent, steps);
|
||||
execute_batch(&self.project, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(Plan::Change(Box::new(command)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,720 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{behavior::topology::occurrence, document::Document};
|
||||
use oa_core::{FragmentOperator, Layer, LifelineBinding, MessageKind, language};
|
||||
|
||||
fn add(p: &mut Project, parent: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let e = occurrence(p, parent, name, body);
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn row(p: &mut Project, parent: Id, name: &str) -> Id {
|
||||
add(
|
||||
p,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
fn operand(p: &mut Project, parent: Id, operator: FragmentOperator) -> (Id, Id) {
|
||||
let fragment = add(
|
||||
p,
|
||||
parent,
|
||||
"Fragment",
|
||||
OccurrenceBody::Fragment {
|
||||
operator,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = occurrence(
|
||||
p,
|
||||
fragment,
|
||||
"Operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = branch.id;
|
||||
p.elements.insert(id, branch);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(id);
|
||||
}
|
||||
(fragment, id)
|
||||
}
|
||||
fn prepare(p: &Project, owner: Id, ids: &[Id]) -> Result<Prepared, String> {
|
||||
Prepared::new(
|
||||
Arc::new(p.clone()),
|
||||
owner,
|
||||
&ids.iter()
|
||||
.copied()
|
||||
.map(Target::ScenarioObject)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
fn slot(parent: Id, before: Option<Id>) -> Slot {
|
||||
Slot { parent, before }
|
||||
}
|
||||
fn ordered(p: &Project, parent: Id, ids: &[Id]) -> Project {
|
||||
let mut expected = p.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = ids.to_vec();
|
||||
expected
|
||||
}
|
||||
fn exact_transaction(p: &Project, plan: Plan, expected: Project) {
|
||||
let Plan::Change(command) = plan else {
|
||||
panic!("expected a change")
|
||||
};
|
||||
assert_eq!(
|
||||
execute_batch(p, std::slice::from_ref(&command)).unwrap(),
|
||||
expected
|
||||
);
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let original_source = doc.bundle().clone();
|
||||
doc.execute(*command).unwrap();
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(doc.project())).unwrap(),
|
||||
expected
|
||||
);
|
||||
let changed_source = doc.bundle().clone();
|
||||
assert_eq!(
|
||||
Document::from_bundle(changed_source.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), p);
|
||||
assert_eq!(doc.bundle(), &original_source);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), &expected);
|
||||
assert_eq!(doc.bundle(), &changed_source);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
fn four(nested: bool) -> (Project, Id, Id, [Id; 4]) {
|
||||
four_in_layer(nested, Layer::Oa)
|
||||
}
|
||||
fn four_in_layer(nested: bool, layer: Layer) -> (Project, Id, Id, [Id; 4]) {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(oa_core::Kind::Scenario, layer, "Scenario", None)
|
||||
.unwrap();
|
||||
let parent = if nested {
|
||||
operand(&mut p, owner, FragmentOperator::Alternative).1
|
||||
} else {
|
||||
owner
|
||||
};
|
||||
let ids = ["A", "B", "C", "D"].map(|name| row(&mut p, parent, name));
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
(p, owner, parent, ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_and_operand_use_semantic_block_order_with_exact_history() {
|
||||
for (nested, layer) in [
|
||||
(false, Layer::Oa),
|
||||
(true, Layer::Oa),
|
||||
(false, Layer::Sa),
|
||||
(true, Layer::Sa),
|
||||
] {
|
||||
let (p, owner, parent, [a, b, c, d]) = four_in_layer(nested, layer);
|
||||
let prepared = prepare(&p, owner, &[c, a]).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, Some(d))).unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, None)).unwrap(),
|
||||
ordered(&p, parent, &[b, d, a, c]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_wrapper_noop_preserves_existing_redo_and_source() {
|
||||
for nested in [false, true] {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(nested);
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let change = super::super::reorder(doc.project(), owner, a, slot(parent, None)).unwrap();
|
||||
doc.execute(change).unwrap();
|
||||
assert_eq!(doc.project(), &ordered(&p, parent, &[b, c, d, a]));
|
||||
let changed = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
let source = doc.bundle().clone();
|
||||
let revision = doc.revision();
|
||||
for before in [Some(a), Some(b)] {
|
||||
let noop =
|
||||
super::super::reorder(doc.project(), owner, a, slot(parent, before)).unwrap();
|
||||
doc.execute(noop).unwrap();
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), &source);
|
||||
assert_eq!(doc.revision(), revision);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.can_redo());
|
||||
}
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_anchor_and_exact_noop_preserve_redo_and_frozen_snapshot() {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(false);
|
||||
let prepared = prepare(&p, owner, &[c, a]).unwrap();
|
||||
let mut doc = Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let Plan::Change(command) = prepared.plan(slot(parent, Some(d))).unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
doc.execute(*command).unwrap();
|
||||
let changed = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
let source = doc.bundle().clone();
|
||||
let revision = doc.revision();
|
||||
for plan in [
|
||||
prepared.plan(slot(parent, Some(c))),
|
||||
prepare(&p, owner, &[a])
|
||||
.unwrap()
|
||||
.plan(slot(parent, Some(b))),
|
||||
prepare(&p, owner, &[c, d])
|
||||
.unwrap()
|
||||
.plan(slot(parent, None)),
|
||||
] {
|
||||
assert!(matches!(plan.unwrap(), Plan::Noop));
|
||||
assert_eq!(doc.project(), &p);
|
||||
assert_eq!(doc.bundle(), &source);
|
||||
assert_eq!(doc.revision(), revision);
|
||||
assert!(doc.can_redo());
|
||||
}
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &changed);
|
||||
// The earlier prepared value still plans from its original owned snapshot.
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(parent, Some(d))).unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_rejects_every_invalid_member_list_and_destination_before_noop() {
|
||||
let (p, owner, parent, [a, b, c, d]) = four(false);
|
||||
for ids in [vec![], vec![a, a], vec![a, Id::new_v4()]] {
|
||||
assert!(prepare(&p, owner, &ids).is_err());
|
||||
}
|
||||
for target in [Target::Whole, Target::Placement(a)] {
|
||||
assert!(
|
||||
Prepared::new(
|
||||
Arc::new(p.clone()),
|
||||
owner,
|
||||
&[Target::ScenarioObject(a), target]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut with_line = p.clone();
|
||||
let line = with_line
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Line", Some(owner))
|
||||
.unwrap();
|
||||
assert!(prepare(&with_line, owner, &[a, line]).is_err());
|
||||
let mut nested = p.clone();
|
||||
let (fragment, branch) = operand(&mut nested, owner, FragmentOperator::Alternative);
|
||||
let child = row(&mut nested, branch, "Child");
|
||||
assert!(prepare(&nested, owner, &[a, child]).is_err());
|
||||
assert!(prepare(&nested, owner, &[fragment, child]).is_err());
|
||||
let prepared = prepare(&p, owner, &[a, c]).unwrap();
|
||||
assert!(prepared.plan(slot(branch, Some(a))).is_err());
|
||||
assert!(prepared.plan(slot(parent, Some(Id::new_v4()))).is_err());
|
||||
for list in [vec![a, b, c, b], vec![a, Id::new_v4(), c, d]] {
|
||||
assert!(prepare(&ordered(&p, parent, &list), owner, &[a, c]).is_err());
|
||||
}
|
||||
let mut wrong_parent = p.clone();
|
||||
wrong_parent.elements.get_mut(&b).unwrap().parent = Some(branch);
|
||||
assert!(prepare(&wrong_parent, owner, &[a, c]).is_err());
|
||||
for body in [
|
||||
OccurrenceBody::Control {
|
||||
kind: oa_core::ControlKind::Choice,
|
||||
},
|
||||
OccurrenceBody::SequenceReference { sequence: None },
|
||||
] {
|
||||
let mut wrong_type = p.clone();
|
||||
if let Some(SharedBody::Occurrence { body: actual, .. }) =
|
||||
wrong_type.elements.get_mut(&b).unwrap().shared_mut()
|
||||
{
|
||||
*actual = body;
|
||||
}
|
||||
assert!(prepare(&wrong_type, owner, &[a, c]).is_err());
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = nested.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
{
|
||||
branches.clear();
|
||||
}
|
||||
assert!(
|
||||
prepare(&nested, owner, &[child]).is_err(),
|
||||
"parent ancestry cannot admit omitted operand"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_warning_child_is_preserved_but_cannot_be_selected() {
|
||||
let (mut p, owner, parent, [a, b, c, d]) = four(false);
|
||||
let hidden = row(&mut p, parent, "Hidden draft");
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.pop();
|
||||
assert!(
|
||||
oa_core::validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "unordered_occurrence")
|
||||
);
|
||||
assert!(prepare(&p, owner, &[hidden]).is_err());
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, owner, &[c, a])
|
||||
.unwrap()
|
||||
.plan(slot(parent, Some(d)))
|
||||
.unwrap(),
|
||||
ordered(&p, parent, &[b, a, c, d]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_reply_moves_as_explicit_stable_pair_and_invalid_baseline_refuses_noop() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
|
||||
let prepared = prepare(&p, f.owner, &[f.reply, f.call]).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
|
||||
);
|
||||
assert!(
|
||||
prepare(&p, f.owner, &[f.reply])
|
||||
.unwrap()
|
||||
.plan(slot(f.owner, Some(f.call)))
|
||||
.unwrap_err()
|
||||
.contains("reply_order")
|
||||
);
|
||||
let invalid = ordered(&p, f.owner, &[f.reply, x, f.call, y]);
|
||||
assert!(prepare(&invalid, f.owner, &[x]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_fragment_descendant_reply_is_validated_and_subtree_preserved() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
|
||||
p.elements.get_mut(&f.reply).unwrap().parent = Some(inner);
|
||||
p.elements
|
||||
.get_mut(&inner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.reply);
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, fragment, y]);
|
||||
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(f.call)))
|
||||
.unwrap_err()
|
||||
.contains("reply_order")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[f.call, y, x, fragment]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_lifetime_and_opaque_local_bindings_are_checked_without_expansion() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = execute_batch(
|
||||
&f.project,
|
||||
&[Command::Delete(f.reply), Command::Delete(f.call)],
|
||||
)
|
||||
.unwrap();
|
||||
let create = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Create",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(f.sender),
|
||||
receiver: Some(f.receiver),
|
||||
message_kind: MessageKind::Create,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (fragment, outer) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
let (_, inner) = operand(&mut p, outer, FragmentOperator::Parallel);
|
||||
add(
|
||||
&mut p,
|
||||
inner,
|
||||
"Use local",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
let prepared = prepare(&p, f.owner, &[fragment, x]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(create)))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, None)).unwrap(),
|
||||
ordered(&p, f.owner, &[create, y, x, fragment]),
|
||||
);
|
||||
let definition = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Definition", None)
|
||||
.unwrap();
|
||||
let represented = match p.elements[&f.receiver].shared().unwrap() {
|
||||
SharedBody::Lifeline { represented } => *represented,
|
||||
_ => panic!(),
|
||||
};
|
||||
let remote = p
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Formal", Some(definition))
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&remote).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::Lifeline { represented };
|
||||
let uses = ["First use", "Second use"].map(|name| {
|
||||
add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
name,
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(definition),
|
||||
bindings: vec![LifelineBinding {
|
||||
referenced: remote,
|
||||
local: f.receiver,
|
||||
}],
|
||||
},
|
||||
)
|
||||
});
|
||||
let prepared = prepare(&p, f.owner, &[uses[1], uses[0]]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, Some(create)))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepared.plan(slot(f.owner, Some(x))).unwrap(),
|
||||
ordered(&p, f.owner, &[create, uses[0], uses[1], x, fragment, y]),
|
||||
);
|
||||
}
|
||||
|
||||
fn delete_line(p: &mut Project, parent: Id, sender: Id, receiver: Id) {
|
||||
add(
|
||||
p,
|
||||
parent,
|
||||
"Delete participant",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver),
|
||||
message_kind: MessageKind::Delete,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn second_operand(p: &mut Project, fragment: Id) -> Id {
|
||||
let branch = occurrence(
|
||||
p,
|
||||
fragment,
|
||||
"Other operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "otherwise".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let id = branch.id;
|
||||
p.elements.insert(id, branch);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
branches.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_delete_rejects_moving_an_earlier_use_after_the_fragment() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let use_line = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Use participant",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Alternative);
|
||||
delete_line(&mut p, branch, f.sender, f.receiver);
|
||||
row(&mut p, f.owner, "Y");
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let prepared = prepare(&p, f.owner, &[x, use_line]).unwrap();
|
||||
assert!(
|
||||
prepared
|
||||
.plan(slot(f.owner, None))
|
||||
.unwrap_err()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exclusive_operand_move_is_legal_but_parallel_baseline_is_invalid() {
|
||||
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let (fragment, left) = operand(&mut p, f.owner, operator);
|
||||
let right = second_operand(&mut p, fragment);
|
||||
delete_line(&mut p, left, f.sender, f.receiver);
|
||||
let use_line = add(
|
||||
&mut p,
|
||||
right,
|
||||
"Use participant",
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: Some(f.receiver),
|
||||
},
|
||||
);
|
||||
let x = row(&mut p, right, "X");
|
||||
let y = row(&mut p, right, "Y");
|
||||
if operator == FragmentOperator::Alternative {
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[x, use_line])
|
||||
.unwrap()
|
||||
.plan(slot(right, None))
|
||||
.unwrap(),
|
||||
ordered(&p, right, &[y, use_line, x]),
|
||||
);
|
||||
} else {
|
||||
// This is an invalid input control, not a valid model broken by reorder.
|
||||
assert!(
|
||||
oa_core::session::validate_candidate(&p)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("lifeline_lifetime")
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[x, use_line]).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_reply_warning_does_not_block_independent_sibling_reorder() {
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let (_, branch) = operand(&mut p, f.owner, FragmentOperator::Loop);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.reply);
|
||||
p.elements.get_mut(&f.reply).unwrap().parent = Some(branch);
|
||||
p.elements
|
||||
.get_mut(&branch)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.reply);
|
||||
let x = row(&mut p, branch, "X");
|
||||
let y = row(&mut p, branch, "Y");
|
||||
let z = row(&mut p, branch, "Z");
|
||||
let warning = |project: &Project| {
|
||||
oa_core::validation::validate(project).iter().any(|issue| {
|
||||
issue.element == Some(f.reply)
|
||||
&& issue.code == "reply_repetition"
|
||||
&& issue.severity == oa_core::validation::Severity::Warning
|
||||
})
|
||||
};
|
||||
assert!(warning(&p));
|
||||
let expected = ordered(&p, branch, &[f.reply, y, x, z]);
|
||||
assert!(warning(&expected));
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[z, x])
|
||||
.unwrap()
|
||||
.plan(slot(branch, None))
|
||||
.unwrap(),
|
||||
expected,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_reorder_preserves_affected_and_unrelated_authored_layouts() {
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition,
|
||||
ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
let f = super::super::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let x = row(&mut p, f.owner, "X");
|
||||
let y = row(&mut p, f.owner, "Y");
|
||||
p = ordered(&p, f.owner, &[f.call, x, f.reply, y]);
|
||||
for (alias, offset) in [("group_saved_a", 0.), ("group_saved_b", 1500.)] {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: "Preserve my layout".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: -300. + offset,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.receiver,
|
||||
x: 700. + offset,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.call,
|
||||
},
|
||||
send_y: 123. + offset,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.reply,
|
||||
},
|
||||
send_y: 567. + offset,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
let unrelated = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Unrelated scenario", None)
|
||||
.unwrap();
|
||||
let line = p
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Unrelated clock",
|
||||
Some(unrelated),
|
||||
)
|
||||
.unwrap();
|
||||
let timer = add(
|
||||
&mut p,
|
||||
unrelated,
|
||||
"Wait",
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(line),
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Timer {
|
||||
expression: "5 s".into(),
|
||||
},
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
let diagram = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "group_unrelated_saved".into(),
|
||||
name: "Unrelated arrangement".into(),
|
||||
description: "Do not touch this view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario {
|
||||
scenario: unrelated,
|
||||
},
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: line,
|
||||
x: -123.,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: timer,
|
||||
},
|
||||
send_y: 987.,
|
||||
}],
|
||||
})),
|
||||
},
|
||||
);
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
exact_transaction(
|
||||
&p,
|
||||
prepare(&p, f.owner, &[f.reply, f.call])
|
||||
.unwrap()
|
||||
.plan(slot(f.owner, None))
|
||||
.unwrap(),
|
||||
ordered(&p, f.owner, &[x, y, f.call, f.reply]),
|
||||
);
|
||||
}
|
||||
@@ -218,7 +218,6 @@ fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestu
|
||||
let stamp = spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: 17,
|
||||
selection_generation: 0,
|
||||
};
|
||||
ready_scene.coordinate_input = true;
|
||||
ready_scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
//! Immutable semantic reorder operation, independent of its destination UI.
|
||||
use super::{
|
||||
Context, Slot,
|
||||
group_reorder::{Plan, Prepared},
|
||||
presentation, selection,
|
||||
};
|
||||
use crate::{diagram_selection::Snapshot, diagram_usage::Target};
|
||||
use oa_core::{Command, Id, Project, execute_batch};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Outcome {
|
||||
Change,
|
||||
Noop,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub(crate) struct Preview {
|
||||
pub(crate) slot: Slot,
|
||||
pub(crate) before: Vec<Id>,
|
||||
pub(crate) after: Vec<Id>,
|
||||
pub(crate) outcome: Outcome,
|
||||
/// Geometry status is independent of semantic validity; requested Saved
|
||||
/// representation is retained even when presentation reports fallback.
|
||||
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
|
||||
pub(crate) selection_visible: bool,
|
||||
command: Option<Box<Command>>,
|
||||
}
|
||||
|
||||
pub(crate) struct Completion {
|
||||
pub(crate) command: Option<Box<Command>>,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) representation: super::Representation,
|
||||
pub(crate) presentation_status: presentation::PresentationStatus,
|
||||
}
|
||||
|
||||
pub(crate) struct Session {
|
||||
project: Arc<Project>,
|
||||
context: Context,
|
||||
snapshot: Snapshot,
|
||||
token: Id,
|
||||
primary: Id,
|
||||
parent: Id,
|
||||
before: Vec<Id>,
|
||||
prepared: Prepared,
|
||||
preview: Option<Preview>,
|
||||
#[cfg(test)]
|
||||
proposal_builds: usize,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(
|
||||
project: Arc<Project>,
|
||||
context: Context,
|
||||
snapshot: Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Self, String> {
|
||||
if token.is_nil()
|
||||
|| context.document.is_nil()
|
||||
|| context.visit.is_nil()
|
||||
|| snapshot.context() != selection::context(context)
|
||||
|| presentation::checked_owner(&project, context.representation)
|
||||
!= Some(context.scenario)
|
||||
{
|
||||
return Err("The reorder input no longer matches this scenario view.".into());
|
||||
}
|
||||
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
|
||||
return Err("Select ordered scenario occurrences.".into());
|
||||
};
|
||||
let primary = *primary;
|
||||
let visible = selection::visible(&project, context, primary);
|
||||
if !snapshot
|
||||
.targets()
|
||||
.contains(&Target::ScenarioObject(primary))
|
||||
|| snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.any(|target| !visible.contains(target))
|
||||
{
|
||||
return Err("Every selected occurrence must be visible in this scenario view.".into());
|
||||
}
|
||||
let prepared = Prepared::new(project.clone(), context.scenario, snapshot.targets())?;
|
||||
let parent = project.elements[&primary]
|
||||
.parent
|
||||
.expect("prepared occurrence parent");
|
||||
let before = project.elements[&parent]
|
||||
.steps()
|
||||
.expect("prepared sequence")
|
||||
.clone();
|
||||
Ok(Self {
|
||||
project,
|
||||
context,
|
||||
snapshot,
|
||||
token,
|
||||
primary,
|
||||
parent,
|
||||
before,
|
||||
prepared,
|
||||
preview: None,
|
||||
#[cfg(test)]
|
||||
proposal_builds: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
pub(crate) fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
pub(crate) fn snapshot(&self) -> &Snapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
pub(crate) fn parent(&self) -> Id {
|
||||
self.parent
|
||||
}
|
||||
pub(crate) fn before(&self) -> &[Id] {
|
||||
&self.before
|
||||
}
|
||||
|
||||
pub(crate) fn current_preview(&self) -> Option<&Preview> {
|
||||
self.preview.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn current_preview_mut(&mut self) -> Option<&mut Preview> {
|
||||
self.preview.as_mut()
|
||||
}
|
||||
|
||||
pub(crate) fn matches(
|
||||
&self,
|
||||
current: &Project,
|
||||
context: Context,
|
||||
snapshot: &Snapshot,
|
||||
token: Id,
|
||||
) -> bool {
|
||||
current == self.project.as_ref()
|
||||
&& context == self.context
|
||||
&& snapshot == &self.snapshot
|
||||
&& token == self.token
|
||||
}
|
||||
|
||||
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
|
||||
if self
|
||||
.preview
|
||||
.as_ref()
|
||||
.is_none_or(|preview| preview.slot != slot)
|
||||
{
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.proposal_builds += 1;
|
||||
}
|
||||
let result = self.prepared.plan(slot).and_then(|plan| match plan {
|
||||
Plan::Noop => Ok((None, self.project.clone())),
|
||||
Plan::Change(command) => {
|
||||
let candidate =
|
||||
execute_batch(&self.project, std::slice::from_ref(command.as_ref()))
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((Some(command), Arc::new(candidate)))
|
||||
}
|
||||
});
|
||||
let preview = match result {
|
||||
Ok((command, candidate)) => {
|
||||
let shown = presentation::present(
|
||||
&candidate,
|
||||
self.context.representation,
|
||||
self.primary,
|
||||
);
|
||||
let visible = shown
|
||||
.scene()
|
||||
.map_or_else(Vec::new, |scene| scene.selection_targets());
|
||||
let selection_visible = self
|
||||
.snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible.contains(target));
|
||||
Preview {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: candidate.elements[&self.parent]
|
||||
.steps()
|
||||
.expect("validated sequence")
|
||||
.clone(),
|
||||
outcome: if command.is_some() {
|
||||
Outcome::Change
|
||||
} else {
|
||||
Outcome::Noop
|
||||
},
|
||||
presentation: Some(shown),
|
||||
selection_visible,
|
||||
command,
|
||||
}
|
||||
}
|
||||
Err(reason) => Preview {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: self.before.clone(),
|
||||
outcome: Outcome::Refused(reason),
|
||||
presentation: None,
|
||||
selection_visible: false,
|
||||
command: None,
|
||||
},
|
||||
};
|
||||
self.preview = Some(preview);
|
||||
}
|
||||
self.preview.as_ref().expect("explicit destination preview")
|
||||
}
|
||||
|
||||
/// Consume once; never execute a Document transaction or alter selection.
|
||||
pub(crate) fn finish(
|
||||
self,
|
||||
current: &Project,
|
||||
context: Context,
|
||||
snapshot: &Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Completion, String> {
|
||||
if !self.matches(current, context, snapshot, token) {
|
||||
return Err("The reorder input changed; choose the operation again.".into());
|
||||
}
|
||||
let preview = self
|
||||
.preview
|
||||
.ok_or("Choose an explicit destination before applying reorder.")?;
|
||||
if let Outcome::Refused(reason) = preview.outcome {
|
||||
return Err(reason);
|
||||
}
|
||||
let presentation_status = preview
|
||||
.presentation
|
||||
.expect("valid plan presentation")
|
||||
.status()
|
||||
.clone();
|
||||
Ok(Completion {
|
||||
command: preview.command,
|
||||
snapshot: self.snapshot,
|
||||
representation: self.context.representation,
|
||||
presentation_status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,354 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
behavior::{
|
||||
scenario::{Representation, direct_tests},
|
||||
topology::occurrence,
|
||||
},
|
||||
diagram_selection::Selection,
|
||||
};
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
fn row(project: &mut Project, parent: Id, name: &str) -> Id {
|
||||
let element = occurrence(
|
||||
project,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn snapshot(context: Context, ids: &[Id]) -> Snapshot {
|
||||
let mut selection = Selection::new(selection::context(context).scope);
|
||||
for id in ids {
|
||||
selection.add(Target::ScenarioObject(*id), |_| true);
|
||||
}
|
||||
selection.snapshot(selection::context(context)).unwrap()
|
||||
}
|
||||
fn session(project: &Project, context: Context, ids: &[Id]) -> Session {
|
||||
Session::new(
|
||||
Arc::new(project.clone()),
|
||||
context,
|
||||
snapshot(context, ids),
|
||||
Id::new_v4(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_destination_cache_and_exact_whole_model_saved_presentation() {
|
||||
for mode in 0..3 {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let x = row(&mut project, f.owner, "X");
|
||||
let y = row(&mut project, f.owner, "Y");
|
||||
let mut context = direct_tests::context(f.owner);
|
||||
for name in ["A", "B"] {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: name.into(),
|
||||
name: name.into(),
|
||||
description: "Keep this view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![oa_core::ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: if name == "A" { 180. } else { 220. },
|
||||
}],
|
||||
messages: [(f.call, 600.), (f.reply, 800.)]
|
||||
.into_iter()
|
||||
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
if mode != 0 && name == "A" {
|
||||
context.representation = Representation::Saved { diagram: id };
|
||||
if mode == 2 {
|
||||
project.diagrams.get_mut(&id).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: [(f.call, 600.), (f.reply, 200.)]
|
||||
.into_iter()
|
||||
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut operation = session(&project, context, &[f.reply, f.call]);
|
||||
assert_eq!(operation.parent(), f.owner);
|
||||
assert_eq!(operation.before(), &[f.call, f.reply, x, y]);
|
||||
assert_eq!(operation.context(), context);
|
||||
let original_snapshot = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
let slot = Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
};
|
||||
let preview = operation.preview(slot);
|
||||
assert_eq!(preview.before, [f.call, f.reply, x, y]);
|
||||
assert_eq!(preview.after, [x, y, f.call, f.reply]);
|
||||
assert_eq!(preview.outcome, Outcome::Change);
|
||||
assert_eq!(
|
||||
preview.presentation.as_ref().unwrap().requested(),
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(operation.proposal_builds, 1);
|
||||
operation.preview(slot);
|
||||
assert_eq!(operation.proposal_builds, 1);
|
||||
operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
});
|
||||
assert_eq!(operation.proposal_builds, 2);
|
||||
operation.preview(slot);
|
||||
assert_eq!(operation.proposal_builds, 3);
|
||||
assert_eq!(operation.preview.as_ref().unwrap().slot, slot);
|
||||
let completion = operation
|
||||
.finish(&project, context, &original_snapshot, token)
|
||||
.unwrap();
|
||||
assert_eq!(completion.snapshot, original_snapshot);
|
||||
assert_eq!(completion.representation, context.representation);
|
||||
if mode == 2 {
|
||||
assert!(matches!(
|
||||
completion.presentation_status,
|
||||
presentation::PresentationStatus::Fallback { .. }
|
||||
));
|
||||
} else {
|
||||
assert!(matches!(
|
||||
completion.presentation_status,
|
||||
presentation::PresentationStatus::Ready
|
||||
));
|
||||
}
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![x, y, f.call, f.reply];
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[*completion.command.unwrap()]).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.diagrams, project.diagrams);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_destination_is_not_noop_and_selected_anchor_is_explicit_noop() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let captured = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
assert!(
|
||||
operation
|
||||
.finish(&f.project, context, &captured, token)
|
||||
.is_err()
|
||||
);
|
||||
operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let token = operation.token();
|
||||
assert_eq!(
|
||||
operation
|
||||
.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.reply)
|
||||
})
|
||||
.outcome,
|
||||
Outcome::Noop
|
||||
);
|
||||
assert!(
|
||||
operation
|
||||
.finish(&f.project, context, &captured, token)
|
||||
.unwrap()
|
||||
.command
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_candidate_refuses_without_changing_baseline_or_selection() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut project = f.project;
|
||||
let later = row(&mut project, f.owner, "Later action");
|
||||
let mut operation = session(&project, context, &[f.reply]);
|
||||
let captured = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
// A previously valid proposal must not survive a new refused destination.
|
||||
let valid = operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
});
|
||||
assert_eq!(valid.outcome, Outcome::Change);
|
||||
assert_eq!(valid.after, [f.call, later, f.reply]);
|
||||
let preview = operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
});
|
||||
assert!(matches!(&preview.outcome, Outcome::Refused(reason) if reason.contains("reply_order")));
|
||||
assert!(preview.presentation.is_none());
|
||||
assert_eq!(preview.before, preview.after);
|
||||
assert!(
|
||||
operation
|
||||
.finish(&project, context, &captured, token)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_cross_parent_and_unrendered_sets_never_reduce_to_primary() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut project = f.project;
|
||||
let other = project
|
||||
.add_element(oa_core::Kind::Scenario, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let foreign = row(&mut project, other, "Foreign");
|
||||
let fragment = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
"Fragment",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: oa_core::FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let fragment_id = fragment.id;
|
||||
project.elements.insert(fragment_id, fragment);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(fragment_id);
|
||||
let branch = occurrence(
|
||||
&project,
|
||||
fragment_id,
|
||||
"Branch",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let branch_id = branch.id;
|
||||
project.elements.insert(branch_id, branch);
|
||||
if let Some(oa_core::SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&fragment_id).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch_id);
|
||||
}
|
||||
let nested = row(&mut project, branch_id, "Nested");
|
||||
assert!(
|
||||
selection::visible(&project, context, f.call).contains(&Target::ScenarioObject(nested))
|
||||
);
|
||||
for ids in [
|
||||
vec![f.sender, f.call],
|
||||
vec![nested, f.call],
|
||||
vec![foreign, f.call],
|
||||
vec![Id::new_v4(), f.call],
|
||||
] {
|
||||
assert!(
|
||||
Session::new(
|
||||
Arc::new(project.clone()),
|
||||
context,
|
||||
snapshot(context, &ids),
|
||||
Id::new_v4()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_checks_every_immutable_input_and_constructor_rejects_nil_lifetimes() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
for altered in 0..8 {
|
||||
let mut operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let mut token = operation.token();
|
||||
let mut captured = operation.snapshot().clone();
|
||||
let mut current = f.project.clone();
|
||||
let mut live = context;
|
||||
operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
});
|
||||
match altered {
|
||||
0 => {
|
||||
current.elements.get_mut(&f.call).unwrap().name.push('!');
|
||||
}
|
||||
1 => live.epoch += 1,
|
||||
2 => captured = snapshot(context, &[f.reply, f.call]),
|
||||
3 => token = Id::new_v4(),
|
||||
4 => live.visit = Id::new_v4(),
|
||||
5 => live.document = Id::new_v4(),
|
||||
6 => live.revision += 1,
|
||||
_ => {
|
||||
let mut selection = Selection::new(selection::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(f.call), |_| true);
|
||||
selection.add(Target::ScenarioObject(f.reply), |_| true);
|
||||
selection.set_primary(&Target::ScenarioObject(f.call));
|
||||
selection.set_primary(&Target::ScenarioObject(f.reply));
|
||||
captured = selection.snapshot(selection::context(context)).unwrap();
|
||||
assert_eq!(captured.targets(), operation.snapshot().targets());
|
||||
assert_eq!(captured.primary(), operation.snapshot().primary());
|
||||
assert_ne!(captured.generation(), operation.snapshot().generation());
|
||||
}
|
||||
}
|
||||
assert!(operation.finish(¤t, live, &captured, token).is_err());
|
||||
}
|
||||
for altered in 0..3 {
|
||||
let mut live = context;
|
||||
let mut token = Id::new_v4();
|
||||
match altered {
|
||||
0 => live.document = Id::nil(),
|
||||
1 => live.visit = Id::nil(),
|
||||
_ => token = Id::nil(),
|
||||
}
|
||||
assert!(
|
||||
Session::new(
|
||||
Arc::new(f.project.clone()),
|
||||
live,
|
||||
snapshot(live, &[f.call]),
|
||||
token
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Candidate-input experiment, not an accepted new interaction policy.
|
||||
//! Exercise production presentation, nearest-Y picking, and exact semantic slots.
|
||||
use super::{Representation, Slot, group_reorder, presentation};
|
||||
use crate::{behavior::Scene, diagram_usage::Target};
|
||||
use iced::Point;
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, Id, OccurrenceEndpoint, Project, ProjectionKind, ScenarioLayout,
|
||||
ScenarioMessagePosition, execute_batch, language, validation,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
diagram: Id,
|
||||
ids: [Id; 4],
|
||||
}
|
||||
|
||||
fn fixture(ys: [f32; 4]) -> Fixture {
|
||||
let mut project = language::parse(SOURCE).unwrap();
|
||||
let named = |name: &str| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == name)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let owner = named("conversation");
|
||||
let ids = [named("a"), named("b"), named("c"), named("d")];
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "saved".into(),
|
||||
name: "Authored coordinates".into(),
|
||||
description: String::new(),
|
||||
layer: project.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: ids
|
||||
.into_iter()
|
||||
.zip(ys)
|
||||
.map(|(occurrence, send_y)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&project)
|
||||
.iter()
|
||||
.all(|issue| issue.severity != validation::Severity::Error)
|
||||
);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
diagram,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
fn shown(f: &Fixture) -> presentation::ScenarioPresentation {
|
||||
let shown = presentation::present(
|
||||
&f.project,
|
||||
Representation::Saved { diagram: f.diagram },
|
||||
f.ids[0],
|
||||
);
|
||||
assert_eq!(shown.status(), &presentation::PresentationStatus::Ready);
|
||||
assert_eq!(shown.requested(), shown.geometry_representation().unwrap());
|
||||
shown
|
||||
}
|
||||
|
||||
fn before(f: &Fixture, id: Id) -> Slot {
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn y(scene: &Scene, slot: Slot) -> f32 {
|
||||
scene.slots.iter().find(|item| item.slot == slot).unwrap().y
|
||||
}
|
||||
|
||||
fn picked(scene: &Scene, f: &Fixture, world_y: f32) -> Slot {
|
||||
scene
|
||||
.slot_at(Point::new(100., world_y), Some(f.owner))
|
||||
.unwrap()
|
||||
.slot
|
||||
}
|
||||
|
||||
fn exact_change(f: &Fixture, selected: &[Id], slot: Slot, order: &[Id]) {
|
||||
let prepared = group_reorder::Prepared::new(
|
||||
Arc::new(f.project.clone()),
|
||||
f.owner,
|
||||
&selected
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Target::ScenarioObject)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let group_reorder::Plan::Change(command) = prepared.plan(slot).unwrap() else {
|
||||
panic!("expected an actual semantic change")
|
||||
};
|
||||
let actual = execute_batch(&f.project, std::slice::from_ref(command.as_ref())).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = order.to_vec();
|
||||
// Literal full-model oracle: every authored position and other field survives.
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&actual)).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
presentation::present(
|
||||
&actual,
|
||||
Representation::Saved { diagram: f.diagram },
|
||||
selected[0]
|
||||
)
|
||||
.status(),
|
||||
&presentation::PresentationStatus::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossed_saved_slots_are_reachable_but_screen_order_is_not_semantic_order() {
|
||||
let f = fixture([200., 500., 300., 400.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(
|
||||
f.ids.map(|id| y(scene, before(&f, id))),
|
||||
[158., 458., 258., 358.]
|
||||
);
|
||||
let mut spatial = f.ids.to_vec();
|
||||
spatial.sort_by(|a, b| y(scene, before(&f, *a)).total_cmp(&y(scene, before(&f, *b))));
|
||||
assert_eq!(spatial, [a, c, d, b]);
|
||||
for id in f.ids {
|
||||
assert_eq!(picked(scene, &f, y(scene, before(&f, id))), before(&f, id));
|
||||
}
|
||||
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_saved_slots_hide_one_destination_while_exact_chooser_can_select_it() {
|
||||
let f = fixture([200., 300., 300., 500.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, b)), 258.);
|
||||
assert_eq!(y(scene, before(&f, c)), 258.);
|
||||
assert!(
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.position(|item| item.slot == before(&f, b))
|
||||
< scene
|
||||
.slots
|
||||
.iter()
|
||||
.position(|item| item.slot == before(&f, c))
|
||||
);
|
||||
// The two distances are identical for every finite Y; min_by keeps the first
|
||||
// tie. The sample exercises that production behavior, not an exhaustive proof.
|
||||
for quarter_pixel in -4000..=12000 {
|
||||
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, c));
|
||||
}
|
||||
assert_eq!(picked(scene, &f, 258.), before(&f, b));
|
||||
exact_change(&f, &[d, a], before(&f, c), &[b, a, d, c]);
|
||||
exact_change(&f, &[d, a], picked(scene, &f, 258.), &[a, d, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_saved_slots_have_subpixel_hit_regions_without_becoming_unreachable_continuously() {
|
||||
let f = fixture([200., 300., 300.125, 300.25]);
|
||||
let [_, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, b)), 258.);
|
||||
assert_eq!(y(scene, before(&f, c)), 258.125);
|
||||
assert_eq!(y(scene, before(&f, d)), 258.25);
|
||||
assert_eq!(picked(scene, &f, 258.125), before(&f, c));
|
||||
// Fixed scale=1/pan=0: C's open Voronoi interval is only
|
||||
// (258.0625, 258.1875). Integer-pixel samples miss it. This does not assert
|
||||
// that all platforms quantize pointer input to integer coordinates.
|
||||
for screen_y in 0..=600 {
|
||||
assert_ne!(picked(scene, &f, screen_y as f32), before(&f, c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_saved_slot_needs_camera_change_or_nonspatial_destination_input() {
|
||||
let f = fixture([200., 300., 400., 2000.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, d)), 1958.);
|
||||
// Fixed scale=1/pan=0 and a 600px-high canvas. The C/D midpoint is
|
||||
// 1158, beyond the whole viewport, so D cannot win within that viewport.
|
||||
for quarter_pixel in 0..=2400 {
|
||||
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, d));
|
||||
}
|
||||
assert_eq!(picked(scene, &f, 1958.), before(&f, d));
|
||||
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project slot_probe "Slot probe" id "71000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
|
||||
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000004" { viewpoint OperationalActivity; steps [@a, @b, @c, @d]; }
|
||||
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000005" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @target; }
|
||||
oa_occurrence a "A" id "71000000-0000-4000-8000-000000000007" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence b "B" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence c "C" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence d "D" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -1,323 +0,0 @@
|
||||
//! Identity-based destination choice, independent of saved screen coordinates.
|
||||
use super::*;
|
||||
use crate::app::scenario_reorder::{Event, Intent};
|
||||
use crate::behavior::scenario_layout::presentation::PresentationStatus;
|
||||
use iced::widget::{column, opaque, scrollable};
|
||||
|
||||
const PAGE_SIZE: usize = 5;
|
||||
|
||||
pub(crate) fn query_id(token: Id) -> iced::widget::Id {
|
||||
iced::widget::Id::from(format!("scenario-reorder-query:{token}"))
|
||||
}
|
||||
|
||||
pub(crate) fn focus_query(token: Id) -> iced::Task<Message> {
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
struct Find {
|
||||
target: iced::widget::Id,
|
||||
present: bool,
|
||||
}
|
||||
impl Operation for Find {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&iced::widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
_: &mut dyn operation::Focusable,
|
||||
) {
|
||||
self.present |= id == Some(&self.target);
|
||||
}
|
||||
fn finish(&self) -> operation::Outcome<()> {
|
||||
if self.present {
|
||||
operation::Outcome::Chain(Box::new(operation::focusable::focus(
|
||||
self.target.clone(),
|
||||
)))
|
||||
} else {
|
||||
operation::Outcome::None
|
||||
}
|
||||
}
|
||||
}
|
||||
// Generic focus would blur every current widget if the old token vanished.
|
||||
// Discover the exact target without changing focus before chaining it.
|
||||
iced::advanced::widget::operate(Find {
|
||||
target: query_id(token),
|
||||
present: false,
|
||||
})
|
||||
.discard()
|
||||
}
|
||||
|
||||
pub(crate) fn apply_message(app: &App) -> Option<Message> {
|
||||
if !app.scenario_reorder_enabled() {
|
||||
return None;
|
||||
}
|
||||
let session = app.scenario_reorder.session.as_ref()?;
|
||||
let preview = session.current_preview()?;
|
||||
(preview.selection_visible
|
||||
&& !matches!(preview.outcome, reorder_session::Outcome::Refused(_))
|
||||
&& matches!(
|
||||
preview.presentation.as_ref()?.status(),
|
||||
PresentationStatus::Ready | PresentationStatus::Fallback { .. }
|
||||
))
|
||||
.then(|| message(session, Intent::Finish(preview.slot)))
|
||||
}
|
||||
|
||||
pub(crate) fn message(session: &reorder_session::Session, intent: Intent) -> Message {
|
||||
Message::ScenarioReorder(Box::new(Event {
|
||||
context: session.context(),
|
||||
snapshot: session.snapshot().clone(),
|
||||
token: session.token(),
|
||||
intent,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn begin(app: &App) -> Option<Message> {
|
||||
(app.scenario_reorder_enabled() && app.scenario_reorder.session.is_none()).then_some(())?;
|
||||
let snapshot = app.multiselect_snapshot()?;
|
||||
if snapshot.targets().is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Message::ScenarioReorder(Box::new(Event {
|
||||
context: app.scenario_context()?,
|
||||
snapshot,
|
||||
token: Id::new_v4(),
|
||||
intent: Intent::Begin,
|
||||
})))
|
||||
}
|
||||
|
||||
fn label(p: &Project, id: Id) -> String {
|
||||
p.elements.get(&id).map_or_else(
|
||||
|| id.to_string(),
|
||||
|e| format!("{} (@{})", short(&e.name, 38), e.alias),
|
||||
)
|
||||
}
|
||||
|
||||
fn destination(p: &Project, before: &[Id], slot: Slot) -> String {
|
||||
slot.before.map_or_else(
|
||||
|| "End of sequence".into(),
|
||||
|id| {
|
||||
let position = before
|
||||
.iter()
|
||||
.position(|item| *item == id)
|
||||
.map_or(0, |i| i + 1);
|
||||
format!("Before {position}. {}", label(p, id))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn order(p: &Project, ids: &[Id], snapshot: &crate::diagram_selection::Snapshot) -> String {
|
||||
// A bounded neighborhood keeps the moved block and its immediate neighbors
|
||||
// visible. Ellipses explicitly mark omitted, unchanged sequence members.
|
||||
let selected = |id: &Id| {
|
||||
snapshot
|
||||
.targets()
|
||||
.contains(&crate::diagram_usage::Target::ScenarioObject(*id))
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
let mut omitted = false;
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
let near = selected(id)
|
||||
|| index.checked_sub(1).is_some_and(|i| selected(&ids[i]))
|
||||
|| ids.get(index + 1).is_some_and(selected);
|
||||
if !near {
|
||||
omitted = true;
|
||||
continue;
|
||||
}
|
||||
if omitted {
|
||||
parts.push("…".into());
|
||||
omitted = false;
|
||||
}
|
||||
parts.push(format!(
|
||||
"{}{}. {}",
|
||||
if selected(id) { "• " } else { "" },
|
||||
index + 1,
|
||||
label(p, *id)
|
||||
));
|
||||
}
|
||||
if omitted {
|
||||
parts.push("…".into());
|
||||
}
|
||||
parts.join("\n")
|
||||
}
|
||||
|
||||
pub(super) fn panel(app: &App, viewport: Option<Size>) -> Option<Element<'_, Message>> {
|
||||
let session = app.scenario_reorder.session.as_ref()?;
|
||||
let p = app.project();
|
||||
let state = &app.scenario_reorder;
|
||||
let query = state.query.to_lowercase();
|
||||
let slots = session
|
||||
.before()
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Some)
|
||||
.chain(std::iter::once(None))
|
||||
.enumerate()
|
||||
.map(|(index, before)| {
|
||||
let slot = Slot {
|
||||
parent: session.parent(),
|
||||
before,
|
||||
};
|
||||
let title = before.map_or_else(
|
||||
|| "End of sequence".into(),
|
||||
|id| format!("Before {}. {}", index + 1, label(p, id)),
|
||||
);
|
||||
(slot, title)
|
||||
})
|
||||
.filter(|(slot, title)| {
|
||||
title.to_lowercase().contains(&query)
|
||||
|| slot
|
||||
.before
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
e.name.to_lowercase().contains(&query)
|
||||
|| e.alias.to_lowercase().contains(&query)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let pages = slots.len().div_ceil(PAGE_SIZE).max(1);
|
||||
let page = state.page.min(pages - 1);
|
||||
let mut choices = column![].spacing(4).width(Fill);
|
||||
for (slot, title) in slots.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
choices = choices.push(
|
||||
button(
|
||||
text(title.clone())
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
)
|
||||
.width(Fill)
|
||||
.on_press(message(session, Intent::Choose(*slot))),
|
||||
);
|
||||
}
|
||||
if slots.is_empty() {
|
||||
choices = choices
|
||||
.push(text("No matching destination. Try its name, alias or position.").size(12));
|
||||
}
|
||||
let mut body = column![
|
||||
text(format!(
|
||||
"Reorder {} selected",
|
||||
session.snapshot().targets().len()
|
||||
))
|
||||
.size(17),
|
||||
text(format!("Sequence: {}", label(p, session.parent())))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
text("Changes execution order; saved positions stay as authored.").size(12),
|
||||
text_input("Find destination by name, alias or position", &state.query)
|
||||
.id(query_id(session.token()))
|
||||
.on_input(move |query| message(session, Intent::Query(query)))
|
||||
.on_submit_maybe(
|
||||
(slots.len() == 1).then(|| message(session, Intent::Choose(slots[0].0)))
|
||||
)
|
||||
.size(13),
|
||||
choices,
|
||||
row![
|
||||
button("Previous")
|
||||
.on_press_maybe((page > 0).then(|| message(session, Intent::Page(page - 1)))),
|
||||
text(format!("{} / {pages}", page + 1)).size(12),
|
||||
button("Next").on_press_maybe(
|
||||
(page + 1 < pages).then(|| message(session, Intent::Page(page + 1)))
|
||||
)
|
||||
]
|
||||
.spacing(8),
|
||||
]
|
||||
.spacing(8)
|
||||
.width(Fill);
|
||||
let mut apply = None;
|
||||
let mut footer =
|
||||
column![text("Enter previews a unique match. Ctrl+Enter applies; Esc cancels.").size(12)]
|
||||
.spacing(6);
|
||||
if let Some(preview) = session.current_preview() {
|
||||
footer = footer.push(
|
||||
text(format!(
|
||||
"Destination: {}",
|
||||
destination(p, session.before(), preview.slot)
|
||||
))
|
||||
.size(13)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph),
|
||||
);
|
||||
let comparison = row![
|
||||
container(
|
||||
column![
|
||||
text("Before").size(13),
|
||||
text(order(p, &preview.before, session.snapshot()))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
]
|
||||
.width(Fill)
|
||||
)
|
||||
.width(Fill)
|
||||
.id("scenario-reorder-before"),
|
||||
container(
|
||||
column![
|
||||
text("After • selected").size(13),
|
||||
text(order(p, &preview.after, session.snapshot()))
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(iced::widget::text::Wrapping::WordOrGlyph)
|
||||
]
|
||||
.width(Fill)
|
||||
)
|
||||
.width(Fill)
|
||||
.id("scenario-reorder-after")
|
||||
]
|
||||
.spacing(10)
|
||||
.width(Fill);
|
||||
body = body.push(comparison);
|
||||
let feedback = match &preview.outcome {
|
||||
reorder_session::Outcome::Refused(reason) => reason.clone(),
|
||||
_ if !preview.selection_visible => {
|
||||
"This destination would hide a selected occurrence; choose another destination."
|
||||
.into()
|
||||
}
|
||||
_ => match preview.presentation.as_ref().map(|shown| shown.status()) {
|
||||
Some(PresentationStatus::Unavailable { .. }) | None => {
|
||||
"This order cannot be displayed; choose another destination.".into()
|
||||
}
|
||||
Some(status) => {
|
||||
apply = Some(message(session, Intent::Finish(preview.slot)));
|
||||
if matches!(status, PresentationStatus::Fallback { .. }) {
|
||||
"Apply will display an automatic arrangement. Your requested saved view and authored positions are retained; they are not repaired.".into()
|
||||
} else if matches!(preview.outcome, reorder_session::Outcome::Noop) {
|
||||
"This destination keeps the current order; Apply will close without an edit.".into()
|
||||
} else {
|
||||
"The selected occurrences move together in their existing order. One Undo restores the sequence.".into()
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
footer = footer.push(text(feedback).size(12));
|
||||
} else {
|
||||
body = body.push(text("Choose a destination to preview the new sequence.").size(12));
|
||||
}
|
||||
let height = viewport.map_or(500., |size| (size.height - 32.).max(100.));
|
||||
let card = column![
|
||||
scrollable(body)
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.spacing(8)
|
||||
.id("scenario-reorder-body"),
|
||||
footer,
|
||||
row![
|
||||
button("Cancel (Esc)").on_press(message(session, Intent::Cancel)),
|
||||
button("Apply order (Ctrl+Enter)").on_press_maybe(apply)
|
||||
]
|
||||
.spacing(8)
|
||||
]
|
||||
.spacing(10);
|
||||
Some(
|
||||
container(opaque(
|
||||
container(card)
|
||||
.padding(12)
|
||||
.width(480)
|
||||
.height(height.min(630.))
|
||||
.style(style::card),
|
||||
))
|
||||
.align_right(Fill)
|
||||
.padding(8)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
//! Captured exact Scenario selection input; membership grants no movement permission.
|
||||
use super::{Context, presentation};
|
||||
use crate::{
|
||||
app::multiselect::Choice,
|
||||
diagram_selection,
|
||||
diagram_usage::{Surface, Target},
|
||||
};
|
||||
use oa_core::{Id, Project};
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Event {
|
||||
pub context: Context,
|
||||
pub snapshot: diagram_selection::Snapshot,
|
||||
pub intent: Intent,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Intent {
|
||||
Choose(Target, Choice),
|
||||
Clear,
|
||||
Collapse(Target),
|
||||
Refuse(String),
|
||||
}
|
||||
pub(crate) fn context(context: Context) -> diagram_selection::Context {
|
||||
diagram_selection::Context {
|
||||
scope: diagram_selection::Scope {
|
||||
document: context.document,
|
||||
surface: match context.representation {
|
||||
super::Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
super::Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
},
|
||||
revision: context.revision,
|
||||
epoch: context.epoch,
|
||||
}
|
||||
}
|
||||
pub(crate) fn visible(project: &Project, context: Context, selected: Id) -> Vec<Target> {
|
||||
let shown = presentation::present(project, context.representation, selected);
|
||||
shown
|
||||
.scene()
|
||||
.map_or_else(Vec::new, |scene| scene.selection_targets())
|
||||
}
|
||||
pub(crate) fn message(
|
||||
context: Context,
|
||||
snapshot: &diagram_selection::Snapshot,
|
||||
intent: Intent,
|
||||
) -> crate::app::Message {
|
||||
crate::app::Message::ScenarioCanvas(super::CanvasEvent::Selection(Box::new(Event {
|
||||
context,
|
||||
snapshot: snapshot.clone(),
|
||||
intent,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,268 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
Scene, SequenceInput,
|
||||
scenario::{self, CanvasEvent, CanvasTool},
|
||||
};
|
||||
use crate::diagram_selection::Selection;
|
||||
use iced::{
|
||||
Point, Rectangle, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
|
||||
fn fixture(group: bool) -> (scenario::direct_tests::Fixture, Scene, Point) {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let context = scenario::direct_tests::context(f.owner);
|
||||
let mut scene = Scene::sequence(&f.project, f.owner, f.reply);
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let mut selection = Selection::new(super::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(f.call), |_| true);
|
||||
if group {
|
||||
selection.add(Target::ScenarioObject(f.reply), |_| true);
|
||||
}
|
||||
scene.selection = Some((
|
||||
context,
|
||||
selection.snapshot(super::context(context)).unwrap(),
|
||||
));
|
||||
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
|
||||
let point = line.label_at + Vector::new(8., 8.);
|
||||
assert_eq!(scene.hit(point), Some(f.call));
|
||||
(f, scene, point)
|
||||
}
|
||||
fn send(
|
||||
scene: &Scene,
|
||||
state: &mut SequenceInput,
|
||||
event: canvas::Event,
|
||||
point: Option<Point>,
|
||||
) -> Option<CanvasEvent> {
|
||||
scene
|
||||
.update(
|
||||
state,
|
||||
&event,
|
||||
Rectangle::new(Point::ORIGIN, scene.size),
|
||||
point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
.and_then(|message| match message {
|
||||
crate::app::Message::ScenarioCanvas(event) => Some(event),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
fn mouse_event(event: mouse::Event) -> canvas::Event {
|
||||
canvas::Event::Mouse(event)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_group_body_press_emits_one_spacing_lifetime_without_collapsing_selection() {
|
||||
use crate::behavior::scenario::spacing::{self, Event};
|
||||
use crate::behavior::scenario_layout::{Representation, resolve};
|
||||
let (doc, owner, diagram, _, first) = spacing::controller::tests::fixture();
|
||||
let second = doc
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == "second")
|
||||
.unwrap()
|
||||
.0;
|
||||
let mut context = scenario::direct_tests::context(owner);
|
||||
context.representation = Representation::Saved { diagram };
|
||||
let mut scene = resolve(doc.project(), context.representation, second)
|
||||
.unwrap()
|
||||
.scene;
|
||||
let mut selection = Selection::new(super::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(first), |_| true);
|
||||
selection.add(Target::ScenarioObject(second), |_| true);
|
||||
let snapshot = selection.snapshot(super::context(context)).unwrap();
|
||||
let stamp = spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: 9,
|
||||
selection_generation: snapshot.generation(),
|
||||
};
|
||||
scene.selection = Some((context, snapshot.clone()));
|
||||
scene.selected = second;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
scene.coordinate_input = true;
|
||||
scene.spacing_stamp = Some(stamp);
|
||||
let line = scene.lines.iter().find(|line| line.id == first).unwrap();
|
||||
let point = Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y);
|
||||
assert_eq!(scene.hit(point), Some(first));
|
||||
let mut input = SequenceInput::default();
|
||||
let Some(CanvasEvent::Spacing(Event::Begin {
|
||||
stamp: got,
|
||||
token,
|
||||
target,
|
||||
..
|
||||
})) = send(
|
||||
&scene,
|
||||
&mut input,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
)
|
||||
else {
|
||||
panic!("group spacing begins on first press")
|
||||
};
|
||||
assert_eq!(got, stamp);
|
||||
assert_eq!(target, spacing::Target::Message(first));
|
||||
assert!(input.group_press.is_none());
|
||||
let moved = point + Vector::new(0., 24.);
|
||||
assert!(
|
||||
matches!(send(&scene,&mut input,mouse_event(mouse::Event::CursorMoved{position:moved}),Some(moved)),Some(CanvasEvent::Spacing(Event::Move{stamp:s,token:t,..})) if s==stamp && t==token)
|
||||
);
|
||||
assert!(
|
||||
matches!(send(&scene,&mut input,mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),Some(moved)),Some(CanvasEvent::Spacing(Event::Finish{stamp:s,token:t,..})) if s==stamp && t==token)
|
||||
);
|
||||
assert_eq!(scene.selection, Some((context, snapshot)));
|
||||
assert_eq!(scene.selected, second);
|
||||
assert!(input.press.is_none() && input.group_press.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn group_press_retains_snapshot_and_collapses_only_stationary_release_once() {
|
||||
let (f, scene, point) = fixture(true);
|
||||
let mut state = SequenceInput::default();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
let captured = state.group_press.as_ref().unwrap().snapshot.clone();
|
||||
assert_eq!(captured.primary(), Some(&Target::ScenarioObject(f.reply)));
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("captured collapse")
|
||||
};
|
||||
assert_eq!(event.snapshot, captured);
|
||||
assert!(matches!(event.intent, Intent::Collapse(Target::ScenarioObject(id)) if id == f.call));
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn crossed_threshold_return_to_origin_refuses_whole_set_and_outside_release_cancels() {
|
||||
for outside in [false, true] {
|
||||
let (_, scene, point) = fixture(true);
|
||||
let mut state = SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
let moved = point + Vector::new(12., 0.);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::CursorMoved { position: moved }),
|
||||
Some(moved),
|
||||
);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::CursorMoved { position: point }),
|
||||
Some(point),
|
||||
);
|
||||
let event = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
(!outside).then_some(point),
|
||||
);
|
||||
if outside {
|
||||
assert!(event.is_none());
|
||||
} else {
|
||||
let Some(CanvasEvent::Selection(event)) = event else {
|
||||
panic!("whole-set refusal")
|
||||
};
|
||||
assert!(matches!(event.intent, Intent::Refuse(_)));
|
||||
assert_eq!(event.snapshot.targets().len(), 2);
|
||||
}
|
||||
assert!(state.group_press.is_none() && state.press.is_none());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn passive_shift_survives_context_refresh_and_window_loss_clears_it() {
|
||||
let (f, mut scene, point) = fixture(false);
|
||||
let mut state = SequenceInput::default();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("toggle")
|
||||
};
|
||||
assert!(
|
||||
matches!(event.intent, Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if id == f.call)
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
scene.interaction.as_mut().unwrap().0.epoch += 1;
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
None,
|
||||
);
|
||||
assert!(state.modifiers.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn stale_selection_and_fallback_release_do_not_move_primary() {
|
||||
let (_, mut scene, point) = fixture(true);
|
||||
scene.coordinate_input = false;
|
||||
let mut state = SequenceInput::default();
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
let Some(CanvasEvent::Selection(event)) = send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point),
|
||||
) else {
|
||||
panic!("fallback stationary selection remains usable")
|
||||
};
|
||||
assert!(matches!(event.intent, Intent::Collapse(_)));
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Some(point),
|
||||
);
|
||||
scene.selection.as_mut().unwrap().0.visit = Id::new_v4();
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
Some(point)
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.group_press.is_none());
|
||||
}
|
||||
@@ -5,17 +5,13 @@
|
||||
use crate::behavior::scenario_layout::{
|
||||
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
|
||||
};
|
||||
use crate::diagram_usage::Target as SelectionTarget;
|
||||
use oa_core::{
|
||||
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
|
||||
ScenarioMessagePosition, SharedBody, execute_batch,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub(crate) mod controller;
|
||||
#[cfg(test)]
|
||||
mod group_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) use controller::{Event, Stamp};
|
||||
|
||||
@@ -34,7 +30,6 @@ impl Target {
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Refusal {
|
||||
InvalidSelection(&'static str),
|
||||
InvalidSavedView(Id),
|
||||
TargetNotRendered(Target),
|
||||
InvalidDelta,
|
||||
@@ -50,7 +45,6 @@ pub(super) struct Drag {
|
||||
diagram: Id,
|
||||
target: Target,
|
||||
origin: f32,
|
||||
other_origins: Vec<(Target, f32)>,
|
||||
original_geometry: ResolvedScenarioPlan,
|
||||
}
|
||||
|
||||
@@ -64,135 +58,17 @@ pub(super) struct Preview {
|
||||
/// Capture an accepted, actually drawn target. A Saved view displaying automatic
|
||||
/// fallback is not an editable original, even if the target appears in fallback.
|
||||
pub(super) fn capture(project: &Project, diagram: Id, target: Target) -> Result<Drag, Refusal> {
|
||||
capture_targets(project, diagram, target, &[target])
|
||||
}
|
||||
|
||||
/// The exact selected set is one operation, never an eligible subset. Selection
|
||||
/// order and the pressed member do not determine persistence order.
|
||||
pub(super) fn capture_group(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
pressed: Target,
|
||||
targets: &[SelectionTarget],
|
||||
) -> Result<Drag, Refusal> {
|
||||
if targets.is_empty() {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"Select at least one occurrence to move.",
|
||||
));
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut members = Vec::with_capacity(targets.len());
|
||||
for selected in targets {
|
||||
let SelectionTarget::ScenarioObject(id) = selected else {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"Move only local Scenario lifelines or messages.",
|
||||
));
|
||||
};
|
||||
if !seen.insert(*id) {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"The selection contains a duplicate occurrence.",
|
||||
));
|
||||
}
|
||||
let target = match pressed {
|
||||
Target::Lane(_) => Target::Lane(*id),
|
||||
Target::Message(_) => Target::Message(*id),
|
||||
};
|
||||
let Some(element) = project.elements.get(id) else {
|
||||
return Err(Refusal::TargetNotRendered(target));
|
||||
};
|
||||
let same_role = match target {
|
||||
Target::Lane(_) => element.kind() == Kind::Lifeline,
|
||||
Target::Message(_) => matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
),
|
||||
};
|
||||
if !same_role {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"Move a group of lifelines or a group of messages; mixed and other occurrence types are not supported.",
|
||||
));
|
||||
}
|
||||
members.push(target);
|
||||
}
|
||||
if !members.contains(&pressed) {
|
||||
return Err(Refusal::InvalidSelection(
|
||||
"The pressed occurrence is not in the captured selection.",
|
||||
));
|
||||
}
|
||||
members.sort_by_key(|target| target.id());
|
||||
capture_targets(project, diagram, pressed, &members)
|
||||
}
|
||||
|
||||
// Require the actual local sequence/operand path, not just an ancestor pointer.
|
||||
// Cross-operand groups are allowed; no member changes its existing parent.
|
||||
fn local_message(project: &Project, owner: Id, id: Id) -> bool {
|
||||
let mut current = id;
|
||||
let mut visited = BTreeSet::new();
|
||||
while current != owner && visited.insert(current) {
|
||||
let Some(element) = project.elements.get(¤t) else {
|
||||
return false;
|
||||
};
|
||||
let Some(parent) = element.parent.and_then(|id| project.elements.get(&id)) else {
|
||||
return false;
|
||||
};
|
||||
if element.layer() != project.elements[&owner].layer() {
|
||||
return false;
|
||||
}
|
||||
let listed = match parent.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
) && branches.iter().filter(|&&id| id == current).count() == 1
|
||||
}
|
||||
_ => parent
|
||||
.steps()
|
||||
.is_some_and(|steps| steps.iter().filter(|&&id| id == current).count() == 1),
|
||||
};
|
||||
if !listed {
|
||||
return false;
|
||||
}
|
||||
current = parent.id;
|
||||
}
|
||||
current == owner
|
||||
}
|
||||
|
||||
fn captured_origin(
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
geometry: &ResolvedScenarioPlan,
|
||||
target: Target,
|
||||
) -> Result<f32, Refusal> {
|
||||
let representation = Representation::Saved { diagram };
|
||||
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
|
||||
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
|
||||
let element = project.elements.get(&target.id());
|
||||
let origin = match target {
|
||||
Target::Lane(id)
|
||||
if element.is_some_and(|e| {
|
||||
e.kind() == Kind::Lifeline
|
||||
&& e.parent == Some(owner)
|
||||
&& e.layer() == project.elements[&owner].layer()
|
||||
}) =>
|
||||
if element.is_some_and(|e| e.kind() == Kind::Lifeline && e.parent == Some(owner)) =>
|
||||
{
|
||||
// Unassigned headers are still rendered local targets.
|
||||
let headers = geometry
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.id == id && item.role == crate::behavior::SceneHitRole::LifelineHeader
|
||||
})
|
||||
.count();
|
||||
(headers == 1)
|
||||
.then(|| geometry.scene.render_lanes.get(&id).copied())
|
||||
.flatten()
|
||||
// Unassigned lifelines still have drawn headers. `scene.lanes`
|
||||
// excludes them only from semantic message-endpoint hit testing.
|
||||
geometry.scene.render_lanes.get(&id).copied()
|
||||
}
|
||||
Target::Message(id)
|
||||
if element.is_some_and(|e| {
|
||||
@@ -203,16 +79,17 @@ fn captured_origin(
|
||||
..
|
||||
})
|
||||
)
|
||||
}) && local_message(project, owner, id) =>
|
||||
}) =>
|
||||
{
|
||||
// A lifecycle receive row is not the stored send coordinate. Reject
|
||||
// ambiguous repeated drawing IDs rather than choosing the first.
|
||||
let mut lines = geometry.scene.lines.iter().filter(|line| line.id == id);
|
||||
let first = lines
|
||||
.next()
|
||||
// A lifecycle row can denote receive y; the public position means
|
||||
// send y. Read the actual outgoing endpoint, including self calls.
|
||||
geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id)
|
||||
.and_then(|line| line.points.first())
|
||||
.map(|point| point.y);
|
||||
if lines.next().is_some() { None } else { first }
|
||||
.map(|point| point.y)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
@@ -220,38 +97,11 @@ fn captured_origin(
|
||||
if !origin.is_finite() {
|
||||
return Err(Refusal::TargetNotRendered(target));
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
fn capture_targets(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
target: Target,
|
||||
targets: &[Target],
|
||||
) -> Result<Drag, Refusal> {
|
||||
let representation = Representation::Saved { diagram };
|
||||
let owner = checked_owner(project, representation).ok_or(Refusal::InvalidSavedView(diagram))?;
|
||||
let geometry = resolve(project, representation, target.id()).map_err(Refusal::Geometry)?;
|
||||
let origins = targets
|
||||
.iter()
|
||||
.map(|member| {
|
||||
captured_origin(project, owner, &geometry, *member).map(|origin| (*member, origin))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let origin = origins
|
||||
.iter()
|
||||
.find(|(member, _)| *member == target)
|
||||
.expect("admitted pressed member")
|
||||
.1;
|
||||
Ok(Drag {
|
||||
original: project.clone(),
|
||||
diagram,
|
||||
target,
|
||||
origin,
|
||||
other_origins: origins
|
||||
.into_iter()
|
||||
.filter(|(member, _)| *member != target)
|
||||
.collect(),
|
||||
original_geometry: geometry,
|
||||
})
|
||||
}
|
||||
@@ -267,50 +117,28 @@ impl Drag {
|
||||
if original_delta == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
for (target, origin) in
|
||||
std::iter::once((self.target, self.origin)).chain(self.other_origins.iter().copied())
|
||||
{
|
||||
let position = (f64::from(origin) + f64::from(original_delta)) as f32;
|
||||
if !position.is_finite() || position.abs() > oa_core::MAX_COORDINATE {
|
||||
return Err(Refusal::Command(
|
||||
"Scenario positions must be finite and within the shared coordinate bounds."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if position == origin {
|
||||
continue;
|
||||
}
|
||||
let command = match target {
|
||||
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline,
|
||||
x: position,
|
||||
},
|
||||
},
|
||||
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y: position,
|
||||
},
|
||||
},
|
||||
};
|
||||
commands.push((target.id(), command));
|
||||
}
|
||||
if commands.is_empty() {
|
||||
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
|
||||
if position == self.origin {
|
||||
return Ok(None);
|
||||
}
|
||||
commands.sort_by_key(|(id, _)| *id);
|
||||
let mut commands: Vec<_> = commands.into_iter().map(|(_, command)| command).collect();
|
||||
let command = if self.other_origins.is_empty() {
|
||||
commands.pop().expect("changed singleton")
|
||||
} else {
|
||||
Command::Batch(commands)
|
||||
let command = match self.target {
|
||||
Target::Lane(lifeline) => Command::SetScenarioLanePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline,
|
||||
x: position,
|
||||
},
|
||||
},
|
||||
Target::Message(occurrence) => Command::SetScenarioMessagePosition {
|
||||
diagram: self.diagram,
|
||||
position: ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y: position,
|
||||
},
|
||||
},
|
||||
};
|
||||
let project = execute_batch(&self.original, std::slice::from_ref(&command))
|
||||
.map_err(|error| Refusal::Command(error.to_string()))?;
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::behavior::{
|
||||
scenario::{CanvasTool, Context, State},
|
||||
scenario_layout::Diagnostic,
|
||||
};
|
||||
use crate::{diagram_selection::Snapshot, diagram_usage::Target as SelectionTarget};
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -17,7 +16,6 @@ mod timing_tests;
|
||||
pub struct Stamp {
|
||||
pub(crate) context: Context,
|
||||
pub(crate) camera_epoch: u64,
|
||||
pub(crate) selection_generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -74,14 +72,11 @@ pub(crate) struct Session {
|
||||
drag: Drag,
|
||||
preview: Option<Preview>,
|
||||
refusal: Option<Refusal>,
|
||||
group_refusal: Option<Refusal>,
|
||||
ever_crossed: bool,
|
||||
snapshot: Option<Snapshot>,
|
||||
}
|
||||
|
||||
pub(in crate::behavior) struct Paint<'a> {
|
||||
pub(in crate::behavior) scene: &'a Scene,
|
||||
pub(in crate::behavior) selected_count: usize,
|
||||
#[cfg(test)]
|
||||
pub(in crate::behavior) bounds: Rectangle,
|
||||
pub(in crate::behavior) refusal: Option<&'a Refusal>,
|
||||
@@ -103,21 +98,7 @@ pub(crate) enum Finish {
|
||||
|
||||
impl Session {
|
||||
fn configure(&self, scene: &mut Scene) {
|
||||
scene.selected = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.and_then(|s| match s.primary() {
|
||||
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(self.drag.target.id());
|
||||
scene.selection = self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.cloned()
|
||||
.map(|s| (self.stamp.context, s));
|
||||
scene.selected = self.drag.target.id();
|
||||
scene.scale = self.scale;
|
||||
scene.camera_pan = self.pan;
|
||||
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
|
||||
@@ -136,18 +117,8 @@ impl Session {
|
||||
Target::Lane(_) => screen.x - self.press_screen.x,
|
||||
Target::Message(_) => screen.y - self.press_screen.y,
|
||||
};
|
||||
self.ever_crossed |= if self
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
screen.distance(self.press_screen) >= 3.
|
||||
} else {
|
||||
distance.abs() >= 3.
|
||||
};
|
||||
if !self.ever_crossed || self.group_refusal.is_some() {
|
||||
// A rejected whole set may retain a pressed singleton only as its
|
||||
// original display witness. It must never prepare a partial move.
|
||||
self.ever_crossed |= distance.abs() >= 3.;
|
||||
if !self.ever_crossed {
|
||||
return;
|
||||
}
|
||||
match self.drag.prepare(distance / self.scale) {
|
||||
@@ -168,9 +139,6 @@ impl State {
|
||||
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
|
||||
self.spacing.as_ref().map(|s| s.stamp)
|
||||
}
|
||||
pub(crate) fn spacing_snapshot(&self) -> Option<&Snapshot> {
|
||||
self.spacing.as_ref()?.snapshot.as_ref()
|
||||
}
|
||||
pub(crate) fn cancel_spacing(&mut self) -> bool {
|
||||
self.spacing.take().is_some()
|
||||
}
|
||||
@@ -181,55 +149,28 @@ impl State {
|
||||
camera_epoch: u64,
|
||||
) -> Option<Paint<'_>> {
|
||||
let session = self.spacing.as_ref()?;
|
||||
if session.stamp.context != context || session.stamp.camera_epoch != camera_epoch {
|
||||
if session.stamp
|
||||
!= (Stamp {
|
||||
context,
|
||||
camera_epoch,
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let original = &session.drag.original_geometry;
|
||||
let shown = session.preview.as_ref().map_or(original, |p| &p.geometry);
|
||||
Some(Paint {
|
||||
scene: &shown.scene,
|
||||
selected_count: session
|
||||
.snapshot
|
||||
.as_ref()
|
||||
.map_or(1, |s| s.targets().len().max(1)),
|
||||
#[cfg(test)]
|
||||
bounds: shown.bounds,
|
||||
refusal: session.refusal.as_ref().or_else(|| {
|
||||
session
|
||||
.ever_crossed
|
||||
.then_some(session.group_refusal.as_ref())
|
||||
.flatten()
|
||||
}),
|
||||
refusal: session.refusal.as_ref(),
|
||||
original_diagnostics: &original.plan.diagnostics,
|
||||
original_latent: &original.latent_messages,
|
||||
diagnostics: &shown.plan.diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
|
||||
self.begin_spacing_capture(project, event, None)
|
||||
}
|
||||
pub(crate) fn begin_spacing_with_snapshot(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
event: Event,
|
||||
snapshot: Snapshot,
|
||||
) -> Result<(), Refusal> {
|
||||
if snapshot.generation() != event.stamp().selection_generation
|
||||
|| snapshot.context()
|
||||
!= crate::behavior::scenario::selection::context(event.stamp().context)
|
||||
{
|
||||
return Err(Refusal::InvalidGesture);
|
||||
}
|
||||
self.begin_spacing_capture(project, event, Some(snapshot))
|
||||
}
|
||||
fn begin_spacing_capture(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
event: Event,
|
||||
snapshot: Option<Snapshot>,
|
||||
) -> Result<(), Refusal> {
|
||||
let Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
@@ -275,34 +216,7 @@ impl State {
|
||||
let Representation::Saved { diagram } = context.representation else {
|
||||
return Err(Refusal::InvalidGesture);
|
||||
};
|
||||
let mut group_refusal = None;
|
||||
let mut drag = if let Some(selected) = snapshot.as_ref().filter(|s| s.targets().len() > 1) {
|
||||
if !selected
|
||||
.targets()
|
||||
.contains(&SelectionTarget::ScenarioObject(target.id()))
|
||||
{
|
||||
return Err(Refusal::TargetNotHit(target));
|
||||
}
|
||||
match capture_group(project, diagram, target, selected.targets()) {
|
||||
Ok(drag) => drag,
|
||||
Err(error)
|
||||
if matches!(
|
||||
&error,
|
||||
Refusal::InvalidSelection(_) | Refusal::TargetNotRendered(_)
|
||||
) =>
|
||||
{
|
||||
// Selection clicks do not require movement eligibility.
|
||||
// This capture supplies original paint and the exact hit
|
||||
// only; group_refusal forbids calling its prepare method.
|
||||
let original = capture(project, diagram, target)?;
|
||||
group_refusal = Some(error);
|
||||
original
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
} else {
|
||||
capture(project, diagram, target)?
|
||||
};
|
||||
let mut drag = capture(project, diagram, target)?;
|
||||
let camera = self.presentation.camera(drag.original_geometry.bounds);
|
||||
if self.presentation.viewport != Some(canvas_bounds.size())
|
||||
|| camera.scale != scale
|
||||
@@ -332,19 +246,7 @@ impl State {
|
||||
}
|
||||
// Configure baseline without changing the persistent camera or selection.
|
||||
let scene = &mut drag.original_geometry.scene;
|
||||
scene.selected = snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.and_then(|s| match s.primary() {
|
||||
Some(SelectionTarget::ScenarioObject(id)) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(target.id());
|
||||
scene.selection = snapshot
|
||||
.as_ref()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
.cloned()
|
||||
.map(|s| (context, s));
|
||||
scene.selected = target.id();
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = pan;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
@@ -360,9 +262,7 @@ impl State {
|
||||
drag,
|
||||
preview: None,
|
||||
refusal: None,
|
||||
group_refusal,
|
||||
ever_crossed: false,
|
||||
snapshot,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -382,9 +282,6 @@ impl State {
|
||||
if !session.ever_crossed {
|
||||
return Ok(Finish::Click(session.drag.target.id()));
|
||||
}
|
||||
if let Some(error) = session.group_refusal {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(match session.preview {
|
||||
None => Finish::Noop,
|
||||
Some(preview) => Finish::Commit {
|
||||
|
||||
@@ -95,7 +95,6 @@ pub(crate) fn packet(
|
||||
stamp: Stamp {
|
||||
context,
|
||||
camera_epoch,
|
||||
selection_generation: 0,
|
||||
},
|
||||
token: Id::new_v4(),
|
||||
target,
|
||||
@@ -136,55 +135,6 @@ fn press(event: Event) -> Point {
|
||||
press_screen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_preview_keeps_captured_members_and_nonpressed_primary() {
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target as Selected;
|
||||
let (doc, mut state, mut event) = setup(true);
|
||||
let context = event.stamp().context;
|
||||
let first = match event {
|
||||
Event::Begin { target, .. } => target.id(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let second = doc
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "second")
|
||||
.unwrap()
|
||||
.0;
|
||||
let selection_context = crate::behavior::scenario::selection::context(context);
|
||||
let mut selection = Selection::new(selection_context.scope);
|
||||
selection.add(Selected::ScenarioObject(first), |_| true);
|
||||
selection.add(Selected::ScenarioObject(second), |_| true);
|
||||
let snapshot = selection.snapshot(selection_context).unwrap();
|
||||
if let Event::Begin { stamp, .. } = &mut event {
|
||||
stamp.selection_generation = snapshot.generation();
|
||||
}
|
||||
let start = press(event);
|
||||
state
|
||||
.begin_spacing_with_snapshot(doc.project(), event, snapshot.clone())
|
||||
.unwrap();
|
||||
for delta in [Vector::new(0., 0.), Vector::new(0., 24.)] {
|
||||
state.move_spacing(start + delta);
|
||||
let paint = state
|
||||
.spacing_paint(context, event.stamp().camera_epoch)
|
||||
.unwrap();
|
||||
assert_eq!(paint.scene.selected, second);
|
||||
assert_eq!(
|
||||
paint.scene.selection.as_ref().unwrap(),
|
||||
&(context, snapshot.clone())
|
||||
);
|
||||
assert_eq!(paint.selected_count, 2);
|
||||
}
|
||||
state.move_spacing(start + Vector::new(50., 0.));
|
||||
assert!(matches!(
|
||||
state.finish_spacing(start + Vector::new(50., 0.)).unwrap(),
|
||||
Finish::Noop
|
||||
));
|
||||
assert!(!doc.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
|
||||
for message in [false, true] {
|
||||
|
||||
@@ -1,675 +0,0 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, ProjectionKind, ScenarioLayout, language};
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn fixture(layout: Option<ScenarioLayout>) -> (Document, Id, Id) {
|
||||
let mut project = language::parse(FIXTURE).unwrap();
|
||||
let owner = id(&project, "conversation");
|
||||
let right = id(&project, "right");
|
||||
let mut views = Vec::new();
|
||||
for (alias, layout) in [
|
||||
("active_view", layout),
|
||||
(
|
||||
"untouched_view",
|
||||
Some(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 700.,
|
||||
}],
|
||||
messages: vec![],
|
||||
}),
|
||||
),
|
||||
] {
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: "Preserve authored view metadata".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: layout.map(DiagramLayout::Scenario),
|
||||
},
|
||||
);
|
||||
views.push(diagram);
|
||||
}
|
||||
(
|
||||
Document::from_project(project, Default::default()).unwrap(),
|
||||
views[0],
|
||||
views[1],
|
||||
)
|
||||
}
|
||||
|
||||
fn targets(ids: &[Id]) -> Vec<SelectionTarget> {
|
||||
ids.iter()
|
||||
.copied()
|
||||
.map(SelectionTarget::ScenarioObject)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn message(occurrence: Id, send_y: f32) -> ScenarioMessagePosition {
|
||||
ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
}
|
||||
}
|
||||
|
||||
// Literal expected authored payload, not another execution of planner commands.
|
||||
fn expected(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
lanes: Vec<ScenarioLanePosition>,
|
||||
messages: Vec<ScenarioMessagePosition>,
|
||||
) -> Project {
|
||||
let mut result = project.clone();
|
||||
result.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout { lanes, messages }));
|
||||
result
|
||||
}
|
||||
|
||||
fn exact_transaction(mut document: Document, preview: Preview, expected: Project) {
|
||||
let original = document.snapshot();
|
||||
let revision = document.revision();
|
||||
assert_eq!(preview.project, expected);
|
||||
assert!(matches!(&preview.command, Command::Batch(_)));
|
||||
document.execute(preview.command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.revision(), revision + 1);
|
||||
let changed = document.bundle().clone();
|
||||
assert_ne!(changed, original.bundle);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(document.project())).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &original.decoded.project);
|
||||
assert_eq!(document.bundle(), &original.bundle);
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
assert!(!document.can_redo());
|
||||
let reopened = Document::from_bundle(changed, Default::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
assert!(!reopened.dirty() && !reopened.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_messages_move_from_each_original_with_press_and_selection_order_independent() {
|
||||
let (document, diagram, other) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let wanted = expected(
|
||||
before,
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(first, 188.), message(second, 280.)],
|
||||
);
|
||||
for members in [[first, second], [second, first]] {
|
||||
for pressed in [first, second] {
|
||||
let drag = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(pressed),
|
||||
&targets(&members),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = drag.prepare(40.).unwrap().unwrap();
|
||||
assert_eq!(preview.project, wanted);
|
||||
assert_eq!(preview.project.diagrams[&other], before.diagrams[&other]);
|
||||
assert_eq!(drag.original, *before);
|
||||
}
|
||||
}
|
||||
let preview = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(40.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
exact_transaction(document, preview, wanted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_lanes_vacate_each_others_space_in_one_candidate_and_one_history_entry() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let left = id(before, "left");
|
||||
let right = id(before, "right");
|
||||
// Moving only Left into Right's old x cannot render a distinct-endpoint arrow.
|
||||
assert!(matches!(
|
||||
capture(before, diagram, Target::Lane(left))
|
||||
.unwrap()
|
||||
.prepare(240.),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
let wanted = expected(
|
||||
before,
|
||||
diagram,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: left,
|
||||
x: 400.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 640.,
|
||||
},
|
||||
],
|
||||
vec![],
|
||||
);
|
||||
for pressed in [left, right] {
|
||||
let preview = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Lane(pressed),
|
||||
&targets(&[right, left]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(240.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview.project, wanted);
|
||||
assert_eq!(preview.geometry.scene.render_lanes[&left], 400.);
|
||||
assert_eq!(preview.geometry.scene.render_lanes[&right], 640.);
|
||||
}
|
||||
let preview = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Lane(right),
|
||||
&targets(&[left, right]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(240.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
exact_transaction(document, preview, wanted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_and_reply_are_validated_together_but_partial_candidate_is_refused() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let call = id(before, "call");
|
||||
let reply = id(before, "reply");
|
||||
let first = id(before, "first");
|
||||
assert!(matches!(
|
||||
capture(before, diagram, Target::Message(call))
|
||||
.unwrap()
|
||||
.prepare(118.),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
let drag = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(reply),
|
||||
&targets(&[reply, call]),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = drag.prepare(118.).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(
|
||||
before,
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(call, 450.), message(reply, 542.)]
|
||||
)
|
||||
);
|
||||
let line = preview
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == call)
|
||||
.unwrap();
|
||||
assert_eq!(line.points.first().unwrap().y, 450.);
|
||||
assert_eq!(line.points.last().unwrap().y, 474.);
|
||||
let partial = capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Message(first),
|
||||
&targets(&[first, call]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(partial.prepare(118.), Err(Refusal::Geometry(_))));
|
||||
assert_eq!(partial.original, *before);
|
||||
assert!(!document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_set_admission_rejects_empty_duplicates_mixed_unsupported_missing_and_unpressed() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let left = id(before, "left");
|
||||
let source = id(before, "source");
|
||||
let unfinished = id(before, "unfinished");
|
||||
for selection in [
|
||||
vec![],
|
||||
targets(&[first, first]),
|
||||
targets(&[first, left]),
|
||||
targets(&[first, source]),
|
||||
targets(&[first, Id::new_v4()]),
|
||||
targets(&[second]),
|
||||
targets(&[first, unfinished]),
|
||||
vec![
|
||||
SelectionTarget::ScenarioObject(first),
|
||||
SelectionTarget::Whole,
|
||||
],
|
||||
vec![
|
||||
SelectionTarget::ScenarioObject(first),
|
||||
SelectionTarget::Placement(second),
|
||||
],
|
||||
] {
|
||||
assert!(
|
||||
capture_group(before, diagram, Target::Message(first), &selection).is_err(),
|
||||
"{selection:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
capture_group(
|
||||
before,
|
||||
Id::nil(),
|
||||
Target::Message(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
capture_group(
|
||||
before,
|
||||
diagram,
|
||||
Target::Lane(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(!document.dirty() && !document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_unlisted_and_ambiguous_local_messages_cannot_enter_a_group() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let owner = id(before, "conversation");
|
||||
for case in 0..3 {
|
||||
let mut project = before.clone();
|
||||
match case {
|
||||
0 => {
|
||||
let foreign = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
project.elements.get_mut(&second).unwrap().parent = Some(foreign);
|
||||
}
|
||||
1 => project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != second),
|
||||
_ => project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(second),
|
||||
}
|
||||
assert!(
|
||||
capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Message(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_baseline_is_refused_before_any_zero_motion_can_be_planned() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let call = id(document.project(), "call");
|
||||
let reply = id(document.project(), "reply");
|
||||
let invalid = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(call, 450.)],
|
||||
);
|
||||
let shown = crate::behavior::scenario_layout::presentation::present(
|
||||
&invalid,
|
||||
Representation::Saved { diagram },
|
||||
call,
|
||||
);
|
||||
assert!(shown.capabilities().geometry_navigation);
|
||||
assert!(!shown.capabilities().coordinate_input);
|
||||
assert!(matches!(
|
||||
capture_group(
|
||||
&invalid,
|
||||
diagram,
|
||||
Target::Message(reply),
|
||||
&targets(&[reply, call])
|
||||
),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_noops_preserve_sparse_overrides_complete_source_and_existing_redo() {
|
||||
for sparse in [true, false] {
|
||||
let (mut document, diagram, _) = fixture((!sparse).then(ScenarioLayout::default));
|
||||
let first = id(document.project(), "first");
|
||||
let second = id(document.project(), "second");
|
||||
let owner = id(document.project(), "conversation");
|
||||
document
|
||||
.execute(Command::Rename(owner, "Redo sentinel".into()))
|
||||
.unwrap();
|
||||
let redo = document.snapshot();
|
||||
assert!(document.undo());
|
||||
let original = document.snapshot();
|
||||
let revision = document.revision();
|
||||
let drag = capture_group(
|
||||
document.project(),
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(drag.prepare(30.).unwrap().is_some());
|
||||
for delta in [0., -0., f32::from_bits(1)] {
|
||||
assert!(drag.prepare(delta).unwrap().is_none());
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
|
||||
assert_eq!(document.bundle(), &original.bundle);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert_eq!(drag.original, original.decoded.project);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &redo.decoded.project);
|
||||
assert_eq!(document.bundle(), &redo.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subprecision_unchanged_member_does_not_materialize_an_absent_override() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let first = id(document.project(), "first");
|
||||
let second = id(document.project(), "second");
|
||||
let project = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
vec![],
|
||||
vec![message(first, 0.)],
|
||||
);
|
||||
let delta = 0.000001_f32;
|
||||
assert_eq!(240_f32 + delta, 240.);
|
||||
let preview = capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(delta)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(&project, diagram, vec![], vec![message(first, delta)])
|
||||
);
|
||||
assert!(matches!(preview.command, Command::Batch(ref commands) if commands.len() == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_delta_or_one_out_of_bounds_member_refuses_the_whole_group() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let left = id(document.project(), "left");
|
||||
let right = id(document.project(), "right");
|
||||
let project = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 999_990.,
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
let drag = capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Lane(left),
|
||||
&targets(&[left, right]),
|
||||
)
|
||||
.unwrap();
|
||||
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
|
||||
}
|
||||
for delta in [20., f32::MAX, -f32::MAX] {
|
||||
assert!(matches!(drag.prepare(delta), Err(Refusal::Command(_))));
|
||||
}
|
||||
assert_eq!(drag.original, project);
|
||||
assert!(drag.prepare(0.).unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn singleton_capture_group_retains_existing_singleton_command_and_result() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
for target in [
|
||||
Target::Lane(id(document.project(), "right")),
|
||||
Target::Message(id(document.project(), "second")),
|
||||
] {
|
||||
let single = capture(document.project(), diagram, target)
|
||||
.unwrap()
|
||||
.prepare(25.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let group = capture_group(
|
||||
document.project(),
|
||||
diagram,
|
||||
target,
|
||||
&targets(&[target.id()]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(25.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(group.project, single.project);
|
||||
assert!(!matches!(group.command, Command::Batch(_)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_layout_order_and_unselected_values_are_preserved_exactly() {
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let first = id(document.project(), "first");
|
||||
let second = id(document.project(), "second");
|
||||
let reply = id(document.project(), "reply");
|
||||
let right = id(document.project(), "right");
|
||||
let lanes = vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 400.,
|
||||
}];
|
||||
let project = expected(
|
||||
document.project(),
|
||||
diagram,
|
||||
lanes.clone(),
|
||||
vec![message(reply, 424.), message(first, 148.)],
|
||||
);
|
||||
let preview = capture_group(
|
||||
&project,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(40.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(
|
||||
&project,
|
||||
diagram,
|
||||
lanes,
|
||||
vec![
|
||||
message(reply, 424.),
|
||||
message(first, 188.),
|
||||
message(second, 280.)
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_messages_in_different_operands_move_without_reparenting_or_reordering() {
|
||||
use crate::behavior::topology::occurrence;
|
||||
let (document, diagram, _) = fixture(None);
|
||||
let mut project = document.project().clone();
|
||||
let owner = id(&project, "conversation");
|
||||
let first = id(&project, "first");
|
||||
let second = id(&project, "second");
|
||||
let fragment = occurrence(
|
||||
&project,
|
||||
owner,
|
||||
"Alternatives",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: oa_core::FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let frame = fragment.id;
|
||||
project.elements.insert(frame, fragment);
|
||||
let mut branches = Vec::new();
|
||||
for member in [first, second] {
|
||||
let branch = occurrence(
|
||||
&project,
|
||||
frame,
|
||||
"Operand",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![member],
|
||||
},
|
||||
);
|
||||
let branch_id = branch.id;
|
||||
project.elements.insert(branch_id, branch);
|
||||
project.elements.get_mut(&member).unwrap().parent = Some(branch_id);
|
||||
branches.push(branch_id);
|
||||
}
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
branches: items, ..
|
||||
},
|
||||
..
|
||||
}) = project.elements.get_mut(&frame).unwrap().shared_mut()
|
||||
{
|
||||
*items = branches.clone();
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
let steps = project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap();
|
||||
steps.retain(|id| ![first, second].contains(id));
|
||||
steps.insert(0, frame);
|
||||
let document = Document::from_project(project, Default::default()).unwrap();
|
||||
let project = document.project();
|
||||
let baseline = resolve(project, Representation::Saved { diagram }, first).unwrap();
|
||||
let original_send = |id| {
|
||||
baseline
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
.y
|
||||
};
|
||||
let preview = capture_group(
|
||||
project,
|
||||
diagram,
|
||||
Target::Message(second),
|
||||
&targets(&[second, first]),
|
||||
)
|
||||
.unwrap()
|
||||
.prepare(-5.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
preview.project,
|
||||
expected(
|
||||
project,
|
||||
diagram,
|
||||
vec![],
|
||||
vec![
|
||||
message(first, original_send(first) - 5.),
|
||||
message(second, original_send(second) - 5.)
|
||||
]
|
||||
)
|
||||
);
|
||||
assert_eq!(preview.project.elements, project.elements);
|
||||
let mut omitted = project.clone();
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment {
|
||||
branches: items, ..
|
||||
},
|
||||
..
|
||||
}) = omitted.elements.get_mut(&frame).unwrap().shared_mut()
|
||||
{
|
||||
items.retain(|id| *id != branches[1]);
|
||||
}
|
||||
assert!(
|
||||
capture_group(
|
||||
&omitted,
|
||||
diagram,
|
||||
Target::Message(first),
|
||||
&targets(&[first, second])
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
const FIXTURE: &str = r#"arcadia 7;
|
||||
project spacing_group "Saved group spacing" id "71000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
|
||||
interaction exchange "Repeated exchange" id "71000000-0000-4000-8000-000000000004" { source @source; target @target; }
|
||||
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
|
||||
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
|
||||
oa_occurrence first "First" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence second "Second" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence call "Call" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
|
||||
oa_occurrence reply "Reply" id "71000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
|
||||
oa_occurrence unfinished "Unfinished" id "71000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -75,7 +75,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let tool = state.canvas_tool(owner);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
// Resolve once per accepted view. An active spacing session owns its exact
|
||||
@@ -91,8 +90,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
},
|
||||
ScenarioPresentation::capabilities,
|
||||
);
|
||||
let coordinate_input =
|
||||
clean && capabilities.coordinate_input && app.scenario_reorder.session.is_none();
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let notice = if let Some(paint) = &preview {
|
||||
display_notice_parts(
|
||||
p,
|
||||
@@ -125,7 +123,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
};
|
||||
let keyboard = clean
|
||||
&& app.scenario_reorder.session.is_none()
|
||||
&& capabilities.geometry_navigation
|
||||
&& super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
@@ -141,21 +138,15 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.selection = snapshot.clone().map(|snapshot| (context, snapshot));
|
||||
scene.spacing_stamp = (coordinate_input
|
||||
&& !app.source_rename.open()
|
||||
&& keyboard
|
||||
&& matches!(context.representation, Representation::Saved { .. }))
|
||||
.then(|| {
|
||||
snapshot.as_ref().map(|snapshot| spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: app.camera_epoch,
|
||||
selection_generation: snapshot.generation(),
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
scene.coordinate_input =
|
||||
capabilities.coordinate_input && app.scenario_reorder.session.is_none();
|
||||
.then_some(spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: app.camera_epoch,
|
||||
});
|
||||
scene.coordinate_input = capabilities.coordinate_input;
|
||||
scene.pending_placement = coordinate_input
|
||||
.then(|| state.pending_placement(owner))
|
||||
.flatten();
|
||||
@@ -167,27 +158,8 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
// Keep this Stack in both branches: replacing the Canvas with a wrapper
|
||||
// on Begin would discard its pointer capture before Move/Finish.
|
||||
let mut layers = iced::widget::stack![raw_canvas];
|
||||
if let Some((_, summary)) = &app.scenario_reorder.summary {
|
||||
layers = layers.push(
|
||||
container(iced::widget::opaque(
|
||||
container(
|
||||
text(format!("{summary} Use Undo to restore the previous order.")).size(12),
|
||||
)
|
||||
.padding(8)
|
||||
.style(style::card),
|
||||
))
|
||||
.align_bottom(Fill)
|
||||
.width(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
if let Some(paint) = &preview {
|
||||
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
||||
let feedback = if paint.selected_count > 1 {
|
||||
format!("{} selected - {feedback}", paint.selected_count)
|
||||
} else {
|
||||
feedback
|
||||
};
|
||||
layers = layers.push(
|
||||
container(
|
||||
container(text(feedback).size(12))
|
||||
@@ -224,46 +196,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
|
||||
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
|
||||
};
|
||||
// The chooser must sit outside Viewport: that camera wrapper deliberately
|
||||
// consumes Space, arrows and wheel before its child receives input.
|
||||
let mut canvas_layers = iced::widget::stack![canvas];
|
||||
if let Some(panel) = reorder_view::panel(app, presentation.viewport) {
|
||||
canvas_layers = canvas_layers.push(panel);
|
||||
}
|
||||
let canvas = canvas_layers.into();
|
||||
let selected_count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
text(format!("{selected_count} selected")).size(12)
|
||||
]
|
||||
.spacing(6);
|
||||
let reorder = reorder_view::begin(app);
|
||||
navigation = navigation.push(super::super::canvas_workspace::Control::new(
|
||||
button(text("Reorder…").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(reorder.clone()),
|
||||
scope,
|
||||
"Reorder",
|
||||
keyboard,
|
||||
reorder,
|
||||
camera,
|
||||
));
|
||||
if selected_count > 1 {
|
||||
let removal = app
|
||||
.scenario_removal_enabled()
|
||||
.then(|| crate::app::scenario_removal::begin(app))
|
||||
.flatten();
|
||||
navigation = navigation.push(super::super::canvas_workspace::Control::new(
|
||||
button(text("Remove…").size(12))
|
||||
.padding(6)
|
||||
.on_press_maybe(removal.clone()),
|
||||
scope,
|
||||
"Remove selected",
|
||||
keyboard,
|
||||
removal,
|
||||
camera,
|
||||
));
|
||||
}
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||
@@ -329,12 +262,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let entries = [
|
||||
(
|
||||
"S",
|
||||
if selected_count > 1 && matches!(context.representation, Representation::Saved { .. })
|
||||
{
|
||||
"Drag messages vertically or lifelines horizontally; Shift-click to change selection"
|
||||
} else if selected_count > 1 {
|
||||
"Shift-click to change selection; click a member to select only it"
|
||||
} else if matches!(context.representation, Representation::Saved { .. }) {
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
"Select / move messages and lifelines"
|
||||
} else {
|
||||
"Select / reorder occurrences"
|
||||
@@ -415,15 +343,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
}),
|
||||
);
|
||||
let mut details = column![
|
||||
text(if selected_count > 1 {
|
||||
format!(
|
||||
"{selected_count} selected; properties: {}",
|
||||
p.named(app.selected)
|
||||
)
|
||||
} else {
|
||||
format!("Selected: {}", p.named(app.selected))
|
||||
})
|
||||
.size(12),
|
||||
text(format!("Selected: {}", p.named(app.selected))).size(12),
|
||||
container(button(text("Properties").size(12)).on_press_maybe(
|
||||
(clean && properties_target(p, context, app.selected)).then_some(
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(context, app.selected)),
|
||||
@@ -447,7 +367,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
let details = details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
sequence_rows(p, context, owner, details, clean, snapshot.as_ref()).into(),
|
||||
sequence_rows(p, context, owner, details, clean).into(),
|
||||
);
|
||||
let mut form_panel = editor_panel(
|
||||
Id::nil(),
|
||||
@@ -765,9 +685,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.into();
|
||||
}
|
||||
}
|
||||
let editor = if app.scenario_reorder.session.is_some() {
|
||||
None
|
||||
} else if pending {
|
||||
let editor = if pending {
|
||||
Some(form_panel)
|
||||
} else {
|
||||
presentation.shelf.map(|_| details)
|
||||
@@ -806,13 +724,9 @@ fn display_notice_parts<'a>(
|
||||
for diagnostic in diagnostics.iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"Saved positions place @{} below @{}, contrary to execution order.",
|
||||
p.elements
|
||||
.get(before)
|
||||
.map_or("?", |element| element.alias.as_str()),
|
||||
p.elements
|
||||
.get(after)
|
||||
.map_or("?", |element| element.alias.as_str())
|
||||
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
||||
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
||||
@@ -837,7 +751,7 @@ fn display_notice_parts<'a>(
|
||||
if let Some(issue) = requested_issues.first() {
|
||||
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
||||
}
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Coordinate editing requires repaired saved positions. Reorder uses explicit sequence destinations and previews its display consequences.").size(12));
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
||||
}
|
||||
PresentationStatus::Unavailable { .. } => {
|
||||
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
||||
@@ -994,24 +908,7 @@ fn sequence_rows<'a>(
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
enabled: bool,
|
||||
snapshot: Option<&crate::diagram_selection::Snapshot>,
|
||||
) -> Column<'a, Message> {
|
||||
let choose = |id| {
|
||||
snapshot.map_or_else(
|
||||
|| Message::ScenarioCanvas(CanvasEvent::Select(context, id)),
|
||||
|snapshot| {
|
||||
selection::message(
|
||||
context,
|
||||
snapshot,
|
||||
selection::Intent::Choose(
|
||||
crate::diagram_usage::Target::ScenarioObject(id),
|
||||
crate::app::multiselect::Choice::Replace,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
};
|
||||
let order_enabled = enabled && snapshot.is_none_or(|snapshot| snapshot.targets().len() <= 1);
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
@@ -1020,10 +917,10 @@ fn sequence_rows<'a>(
|
||||
for branch in branches {
|
||||
out = out.push(sequence_action(
|
||||
&format!("Operand: {}", p.named(*branch)),
|
||||
choose(*branch),
|
||||
Message::Select(*branch),
|
||||
enabled,
|
||||
));
|
||||
out = sequence_rows(p, context, *branch, out, enabled, snapshot);
|
||||
out = sequence_rows(p, context, *branch, out, enabled);
|
||||
}
|
||||
}
|
||||
if let Some(steps) = p.elements.get(&owner).and_then(|e| e.steps()) {
|
||||
@@ -1032,17 +929,17 @@ fn sequence_rows<'a>(
|
||||
row![
|
||||
sequence_action(
|
||||
&format!("{}. {}", i + 1, p.named(*id)),
|
||||
choose(*id),
|
||||
Message::Select(*id),
|
||||
enabled
|
||||
)
|
||||
.width(Fill),
|
||||
button("↑")
|
||||
.padding(8)
|
||||
.on_press_maybe((order_enabled && i > 0).then_some(Message::Command(
|
||||
.on_press_maybe((enabled && i > 0).then_some(Message::Command(
|
||||
Command::MoveStep(owner, i, i.saturating_sub(1))
|
||||
))),
|
||||
button("↓").padding(8).on_press_maybe(
|
||||
(order_enabled && i + 1 < steps.len())
|
||||
(enabled && i + 1 < steps.len())
|
||||
.then_some(Message::Command(Command::MoveStep(owner, i, i + 1)))
|
||||
)
|
||||
]
|
||||
@@ -1063,7 +960,7 @@ fn sequence_rows<'a>(
|
||||
enabled,
|
||||
));
|
||||
}
|
||||
out = sequence_rows(p, context, *id, out, enabled, snapshot);
|
||||
out = sequence_rows(p, context, *id, out, enabled);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -388,16 +388,13 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
|
||||
CanvasEvent::Viewport(c, Size::new(900., 600.))
|
||||
});
|
||||
open_details(&mut app);
|
||||
let mut begin = packet(
|
||||
let begin = packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
Target::Message(message),
|
||||
);
|
||||
if let Spacing::Begin { stamp, .. } = &mut begin {
|
||||
stamp.selection_generation = app.multiselect_snapshot().unwrap().generation();
|
||||
}
|
||||
let Spacing::Begin { press_screen, .. } = begin else {
|
||||
panic!()
|
||||
};
|
||||
@@ -446,180 +443,3 @@ fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish(
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_selection_shift_survives_properties_focus_busy_rebuild_and_clears_on_window_loss() {
|
||||
use crate::app::multiselect::Choice;
|
||||
use crate::behavior::canvas_workspace::tests::focused;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
fn at(mounted: &mut Mounted, app: &App, target: Id) -> Point {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let geometry = resolve(app.project(), context.representation, app.selected).unwrap();
|
||||
let line = geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == target)
|
||||
.unwrap();
|
||||
let world = line.label_at + Vector::new(8., 8.);
|
||||
assert_eq!(geometry.scene.hit_item(world).unwrap().id, target);
|
||||
let bounds = mounted.geometry(app).bounds("scenario-viewport");
|
||||
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
let point = bounds.position() + camera.pan + Vector::new(world.x, world.y) * camera.scale;
|
||||
assert!(bounds.contains(point));
|
||||
point
|
||||
}
|
||||
async fn focus_name(mounted: &mut Mounted, app: &mut App) -> Point {
|
||||
let shown = mounted.geometry(app);
|
||||
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
|
||||
let input = shown
|
||||
.text_inputs
|
||||
.iter()
|
||||
.map(|bounds| bounds.center() - offset)
|
||||
.filter(|point| inspector.contains(*point))
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("visible Name input");
|
||||
mounted.click(app, input).await;
|
||||
assert!(!focused(mounted, app, "viewport"));
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, yes)| *yes && bounds.contains(input + offset))
|
||||
);
|
||||
assert!(!app.draft_dirty());
|
||||
input
|
||||
}
|
||||
let shift = |held| {
|
||||
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if held {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::empty()
|
||||
}))
|
||||
};
|
||||
let (mut app, diagram, first) = setup();
|
||||
let second = id(&app, "updated_report");
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
open_details(&mut app);
|
||||
mounted.settle(&mut app).await;
|
||||
let shown = mounted.geometry(&app);
|
||||
let (details, offset) = shown.scrolls[&widget::Id::new("scenario-details")];
|
||||
let properties = shown.bounds("scenario-properties").center() - offset;
|
||||
assert!(details.contains(properties));
|
||||
mounted.click(&mut app, properties).await;
|
||||
mounted.settle(&mut app).await;
|
||||
let input = focus_name(&mut mounted, &mut app).await;
|
||||
let before = app.document.snapshot();
|
||||
let camera = stored_camera(&app);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let selected = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selected.targets(), &[Target::ScenarioObject(first)]);
|
||||
let passive = mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
|
||||
.await;
|
||||
assert!(
|
||||
!passive
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ScenarioCanvas(_)))
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert_eq!(app.multiselect_snapshot().unwrap(), selected);
|
||||
let second_at = at(&mut mounted, &app, second);
|
||||
let messages = mounted.click(&mut app, second_at).await;
|
||||
assert_eq!(messages.iter().filter(|message| matches!(message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(event))
|
||||
if matches!(&event.intent, selection::Intent::Choose(Target::ScenarioObject(id), Choice::Toggle) if *id == second))).count(), 1);
|
||||
assert!(!messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(_) | CanvasEvent::Reorder { .. })
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[shift(false)],
|
||||
mouse::Cursor::Available(second_at),
|
||||
)
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let first_at = at(&mut mounted, &app, first);
|
||||
let messages = mounted.click(&mut app, first_at).await;
|
||||
// Saved group clicks now complete the captured spacing lifetime; its
|
||||
// stationary outcome must retain the same selection-only collapse.
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
super::super::spacing::Event::Finish { .. }
|
||||
))
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
|
||||
let input = focus_name(&mut mounted, &mut app).await;
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[shift(true)], mouse::Cursor::Available(input))
|
||||
.await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
let second_at = at(&mut mounted, &app, second);
|
||||
mounted.click(&mut app, second_at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Window(iced::window::Event::Unfocused)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let first_at = at(&mut mounted, &app, first);
|
||||
mounted.click(&mut app, first_at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(first))
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
}
|
||||
|
||||
@@ -436,204 +436,3 @@ async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_c
|
||||
assert_eq!(app.project(), &redo.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &redo.bundle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_first_press_spaces_unselected_message_with_existing_singleton_selection() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram) = setup(false).await;
|
||||
let initial = id(&app, "initial_report");
|
||||
let target = id(&app, "updated_report");
|
||||
let context = app.scenario_context().unwrap();
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let task = app.update(selection::message(
|
||||
context,
|
||||
&snapshot,
|
||||
selection::Intent::Choose(
|
||||
Target::ScenarioObject(initial),
|
||||
crate::app::multiselect::Choice::Replace,
|
||||
),
|
||||
));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(initial)]
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = stored_camera(&app);
|
||||
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
|
||||
let start = Point::new(projected.x.round(), projected.y.round());
|
||||
let geometry = resolved(&app);
|
||||
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
assert_eq!(effective.scale, 0.5);
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let world = Point::new(
|
||||
(start.x - bounds.x - effective.pan.x) / effective.scale,
|
||||
(start.y - bounds.y - effective.pan.y) / effective.scale,
|
||||
);
|
||||
let hit = geometry.scene.hit_item(world).unwrap();
|
||||
assert_eq!(hit.id, target);
|
||||
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
|
||||
let messages = press(&mut mounted, &mut app, start).await;
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
!messages
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(_))))
|
||||
);
|
||||
let token = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_token()
|
||||
.expect("first press starts saved spacing despite different selected singleton");
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
let end = start + Vector::new(0., 10.);
|
||||
move_to(&mut mounted, &mut app, end).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
|
||||
let messages = release(&mut mounted, &mut app, end).await;
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
set_expected_message(&mut expected, diagram, target, 272.);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::ScenarioObject(target)]
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::ScenarioObject(target))
|
||||
);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert_reopens_exactly(&app);
|
||||
let after = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let duplicate = release(&mut mounted, &mut app, end).await;
|
||||
assert!(!duplicate.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Finish { .. }))
|
||||
)));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&after, &app.document.snapshot()));
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let task = app.update(Message::Redo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &after.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_blank_left_drag_clears_selection_and_pans_on_that_same_press() {
|
||||
use crate::diagram_usage::Target;
|
||||
for group in [false, true] {
|
||||
for same_batch in [false, true] {
|
||||
let (mut app, mut mounted, _) = setup(false).await;
|
||||
let first = id(&app, "initial_report");
|
||||
let second = id(&app, "updated_report");
|
||||
for target in [Some(first), group.then_some(second)].into_iter().flatten() {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let task = app.update(selection::message(
|
||||
context,
|
||||
&snapshot,
|
||||
selection::Intent::Choose(
|
||||
Target::ScenarioObject(target),
|
||||
crate::app::multiselect::Choice::Add,
|
||||
),
|
||||
));
|
||||
mounted.perform(&mut app, task).await;
|
||||
}
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets().len(),
|
||||
if group { 2 } else { 1 }
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let start = bounds.position() + Vector::new(bounds.width - 25., bounds.height - 25.);
|
||||
let delta = Vector::new(-25., -20.);
|
||||
let end = start + delta;
|
||||
let geometry = resolved(&app);
|
||||
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
let world = Point::new(
|
||||
(start.x - bounds.x - camera.pan.x) / camera.scale,
|
||||
(start.y - bounds.y - camera.pan.y) / camera.scale,
|
||||
);
|
||||
assert!(
|
||||
geometry.scene.hit_item(world).is_none(),
|
||||
"test must start on actual blank geometry"
|
||||
);
|
||||
let stored = stored_camera(&app);
|
||||
let messages = if same_batch {
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
mouse_event(mouse::Event::CursorMoved { position: start }),
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
mouse_event(mouse::Event::CursorMoved { position: end }),
|
||||
mouse_event(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
let mut messages = press(&mut mounted, &mut app, start).await;
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
mounted.settle(&mut app).await;
|
||||
messages.extend(move_to(&mut mounted, &mut app, end).await);
|
||||
messages.extend(release(&mut mounted, &mut app, end).await);
|
||||
messages
|
||||
};
|
||||
assert_eq!(messages.iter().filter(|message| matches!(message, Message::ScenarioCanvas(CanvasEvent::Selection(event)) if matches!(&event.intent, selection::Intent::Clear))).count(), 1);
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
let actual = stored_camera(&app);
|
||||
let difference = actual.0 - (stored.0 + delta);
|
||||
assert!(
|
||||
difference.x.hypot(difference.y) < 0.01,
|
||||
"same-press pan lost after Clear: group={group}, same_batch={same_batch}, {stored:?} -> {actual:?}"
|
||||
);
|
||||
assert_eq!(actual.1, stored.1);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,13 +234,6 @@ impl Rendering {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Exact painted marker subjects and centers for shared selection adapters.
|
||||
pub(in crate::behavior) fn selection_markers(&self) -> impl Iterator<Item = (Id, Point)> + '_ {
|
||||
self.markers
|
||||
.iter()
|
||||
.map(|marker| (marker.event.occurrence, marker.center))
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
|
||||
self.markers
|
||||
.iter()
|
||||
|
||||
@@ -29,7 +29,6 @@ use std::{
|
||||
pub const GUIDANCE_HEIGHT: f32 = 42.;
|
||||
|
||||
pub struct StateCanvas<'a> {
|
||||
pub removal_input: &'a crate::removal_input::Lifetime,
|
||||
pub project: &'a Project,
|
||||
pub diagram: Id,
|
||||
pub context: Context,
|
||||
@@ -45,8 +44,6 @@ pub struct StateCanvas<'a> {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_lease: Option<crate::removal_input::Lease>,
|
||||
removal_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
context: Option<Context>,
|
||||
tool: Option<Tool>,
|
||||
pointer: Option<Point>,
|
||||
@@ -880,21 +877,6 @@ impl StateCanvas<'_> {
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(selection_context(self.context))?;
|
||||
if *modifiers == keyboard::Modifiers::SHIFT {
|
||||
let Some(input_generation) = self.removal_input.idle() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Event::ModelRemoval(Box::new(
|
||||
crate::app::saved_removal::Event::Begin {
|
||||
input_generation,
|
||||
context: self.context,
|
||||
snapshot: Box::new(snapshot),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let event = if modifiers.is_empty()
|
||||
&& crate::app::multiselect::placements(&snapshot).is_some()
|
||||
{
|
||||
@@ -903,7 +885,7 @@ impl StateCanvas<'_> {
|
||||
SelectionEvent::Refuse(snapshot, if modifiers.is_empty() {
|
||||
"Hide requires only placed elements. Rows, ports and transitions cannot be hidden as a placement group."
|
||||
} else {
|
||||
"Use Shift+Delete to review model deletion or Hide selected for placements."
|
||||
"Group model deletion requires an aggregate impact review and is not available yet. Use Hide selected for placements."
|
||||
}.into())
|
||||
};
|
||||
return Some(Action::publish(Event::Selection(event)).and_capture());
|
||||
@@ -1529,8 +1511,9 @@ fn kind_for_tool(tool: Tool) -> Kind {
|
||||
}
|
||||
}
|
||||
|
||||
impl StateCanvas<'_> {
|
||||
fn update_input(
|
||||
impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
@@ -2019,51 +2002,6 @@ impl StateCanvas<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Event> for StateCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Event>> {
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.press.is_some()
|
||||
|| state.group.is_some()
|
||||
|| state.source.is_some()
|
||||
|| state.bend.is_some()
|
||||
|| state.pan_from.is_some(),
|
||||
);
|
||||
self.removal_input.observe(event);
|
||||
let viewport = (self.zoom, self.pan, bounds);
|
||||
if state
|
||||
.removal_viewport
|
||||
.is_some_and(|previous| previous != viewport)
|
||||
{
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
state.removal_viewport = Some(viewport);
|
||||
let action = self.update_input(state, event, bounds, cursor);
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
) {
|
||||
state.reset();
|
||||
}
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.press.is_some()
|
||||
|| state.group.is_some()
|
||||
|| state.source.is_some()
|
||||
|| state.bend.is_some()
|
||||
|| state.pan_from.is_some(),
|
||||
);
|
||||
action
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
state: &State,
|
||||
@@ -2659,7 +2597,6 @@ mod tests {
|
||||
include!("state_multiselect_canvas_tests.rs");
|
||||
|
||||
struct Fixture {
|
||||
removal_input: crate::removal_input::Lifetime,
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
machine: Id,
|
||||
@@ -2721,7 +2658,6 @@ mod tests {
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
Fixture {
|
||||
removal_input: Default::default(),
|
||||
project,
|
||||
diagram,
|
||||
machine,
|
||||
@@ -2922,7 +2858,6 @@ mod tests {
|
||||
|
||||
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
|
||||
StateCanvas {
|
||||
removal_input: &f.removal_input,
|
||||
project: &f.project,
|
||||
diagram: f.diagram,
|
||||
context: Context {
|
||||
|
||||
@@ -26,7 +26,6 @@ pub enum Tool {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Selection(crate::app::multiselect::Event),
|
||||
ModelRemoval(Box<crate::app::saved_removal::Event>),
|
||||
Remove(crate::app::diagram_removal::Intent),
|
||||
Choose(Tool),
|
||||
CreateView(Id),
|
||||
@@ -105,9 +104,7 @@ impl Event {
|
||||
match self {
|
||||
Self::Remove(intent) => Some(intent.context),
|
||||
// Group events carry their own exact selection envelope.
|
||||
Self::Selection(_) | Self::ModelRemoval(_) | Self::Choose(_) | Self::CreateView(_) => {
|
||||
None
|
||||
}
|
||||
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
||||
Self::Select(c, _)
|
||||
| Self::Pan(c, _)
|
||||
| Self::Zoom(c, _)
|
||||
|
||||
@@ -328,10 +328,7 @@ fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
|
||||
let mut state = State::default();
|
||||
use keyboard::key::Named;
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
|
||||
let Some(Event::ModelRemoval(removal)) = group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT) else {
|
||||
panic!("Shift+Delete must capture the complete model-removal group");
|
||||
};
|
||||
assert!(matches!(*removal, crate::app::saved_removal::Event::Begin { snapshot, .. } if snapshot.targets() == selection.targets()));
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
|
||||
@@ -43,7 +43,6 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
fn event(e: Event) -> Message {
|
||||
match e {
|
||||
Event::Selection(selection) => Message::DiagramSelection(selection),
|
||||
Event::ModelRemoval(removal) => Message::SavedRemoval(removal),
|
||||
other => Message::StateDiagram(other),
|
||||
}
|
||||
}
|
||||
@@ -170,7 +169,6 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
);
|
||||
}
|
||||
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
project: p,
|
||||
diagram: id,
|
||||
context,
|
||||
@@ -189,7 +187,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
.into();
|
||||
out = out.push(
|
||||
column![
|
||||
missing_controls,
|
||||
container(missing_controls).id("state-missing-picker"),
|
||||
selection_controls(app),
|
||||
crate::diagram::focus::wrap(
|
||||
canvas.map(event),
|
||||
@@ -225,22 +223,15 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
));
|
||||
}
|
||||
controls = controls.push(
|
||||
button(text("Delete from model…").size(12))
|
||||
.padding([6, 8])
|
||||
.on_press_maybe(
|
||||
crate::app::saved_removal::begin(app).filter(|_| app.saved_removal_enabled()),
|
||||
),
|
||||
);
|
||||
controls = controls
|
||||
.push(action(
|
||||
"Clear",
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 12)
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
|
||||
@@ -5,9 +5,7 @@ use iced::widget::{Column, text_input};
|
||||
use std::collections::BTreeSet;
|
||||
pub(crate) mod contextual;
|
||||
mod direct;
|
||||
pub(crate) mod group_removal;
|
||||
pub(crate) mod group_reorder;
|
||||
use crate::removal_input;
|
||||
mod usages;
|
||||
mod view;
|
||||
#[cfg(test)]
|
||||
@@ -82,7 +80,6 @@ impl std::fmt::Display for ControlChoice {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_input: removal_input::Lifetime,
|
||||
identity: Option<(Id, Id)>,
|
||||
tool: CanvasTool,
|
||||
expanded: BTreeSet<Id>,
|
||||
@@ -131,10 +128,6 @@ struct Draft {
|
||||
contextual: Option<contextual::Draft>,
|
||||
}
|
||||
impl State {
|
||||
pub(crate) fn removal_generation(&self) -> Option<u64> {
|
||||
self.removal_input.idle()
|
||||
}
|
||||
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.tool,
|
||||
@@ -1111,7 +1104,6 @@ const PAINT_PASSES: [PaintPass; 5] = [
|
||||
];
|
||||
mod contextual_routing;
|
||||
struct Graph<'a> {
|
||||
removal_input: removal_input::Lifetime,
|
||||
project: &'a Project,
|
||||
interaction: Option<(Context, CanvasTool, bool)>,
|
||||
camera_pan: iced::Vector,
|
||||
@@ -1409,7 +1401,6 @@ impl<'a> Graph<'a> {
|
||||
}
|
||||
Self {
|
||||
project: p,
|
||||
removal_input: removal_input::Lifetime::default(),
|
||||
interaction: None,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
pending_insertion: None,
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
pub(super) struct GroupPress {
|
||||
_removal_lease: super::super::removal_input::Lease,
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
@@ -109,12 +108,6 @@ impl Graph<'_> {
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
use iced::keyboard::{Event as KeyEvent, Key, key::Named};
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
||||
) {
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
match event {
|
||||
canvas::Event::Keyboard(KeyEvent::ModifiersChanged(modifiers))
|
||||
| canvas::Event::Keyboard(KeyEvent::KeyPressed { modifiers, .. })
|
||||
@@ -212,46 +205,21 @@ impl Graph<'_> {
|
||||
return None;
|
||||
}
|
||||
let publish = |event| Some(Action::publish(Message::DiagramSelection(event)).and_capture());
|
||||
if let canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Delete),
|
||||
modifiers,
|
||||
repeat,
|
||||
..
|
||||
}) = event
|
||||
&& (snapshot.targets().len() > 1
|
||||
|| snapshot.targets().iter().any(|t| match t {
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
_ => false,
|
||||
}))
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Keyboard(KeyEvent::KeyPressed {
|
||||
key: Key::Named(Named::Delete),
|
||||
repeat: false,
|
||||
..
|
||||
})
|
||||
) && (snapshot.targets().len() > 1
|
||||
|| snapshot.targets().iter().any(|t| match t {
|
||||
Target::ProcessPreviewLink { .. } => true,
|
||||
Target::ProcessOccurrence(endpoint) => !endpoint.references.is_empty(),
|
||||
_ => false,
|
||||
}))
|
||||
{
|
||||
if *repeat
|
||||
|| tool != CanvasTool::Select
|
||||
|| !(modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
|| state.press.is_some()
|
||||
|| state.source.is_some()
|
||||
{
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if snapshot.targets().len() > 1 {
|
||||
let Some(input_generation) = self.removal_input.idle() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::ProcessRemoval(Box::new(
|
||||
crate::app::process_removal::Event::Begin {
|
||||
input_generation,
|
||||
context,
|
||||
snapshot: Box::new(snapshot.clone()),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
return publish(SelectionEvent::Refuse(
|
||||
snapshot.clone(),
|
||||
"Open the referenced definition to remove qualified contributions.".into(),
|
||||
));
|
||||
return publish(SelectionEvent::Refuse(snapshot.clone(), "Remove is unavailable for this selection. Open the referenced definition to edit qualified contributions; group removal needs an aggregate review.".into()));
|
||||
}
|
||||
let pointer = match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
|
||||
@@ -260,9 +228,6 @@ impl Graph<'_> {
|
||||
if !bounds.contains(pointer) {
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Mouse(mouse::Event::ButtonPressed(_))) {
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
let world = Point::new(
|
||||
(pointer.x - bounds.x - self.camera_pan.x) / self.scale,
|
||||
(pointer.y - bounds.y - self.camera_pan.y) / self.scale,
|
||||
@@ -345,11 +310,7 @@ impl Graph<'_> {
|
||||
state.tool = tool;
|
||||
state.bounds = Some(bounds);
|
||||
state.pointer = Some(pointer);
|
||||
let Some(lease) = self.removal_input.acquire() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
state.group = Some(GroupPress {
|
||||
_removal_lease: lease,
|
||||
snapshot: snapshot.clone(),
|
||||
target,
|
||||
start: pointer,
|
||||
|
||||
@@ -171,7 +171,7 @@ fn selected_anchor_noop_and_irreversible_threshold_never_collapse_after_motion()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_mixed_set_reviews_delete_and_refuses_drag_and_connect_without_singleton_bypass() {
|
||||
fn whole_mixed_set_refuses_drag_delete_and_connect_without_singleton_bypass() {
|
||||
let f = fixture(Layer::Sa);
|
||||
let mut graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
graph.selected = f.link;
|
||||
@@ -187,7 +187,7 @@ fn whole_mixed_set_reviews_delete_and_refuses_drag_and_connect_without_singleton
|
||||
};
|
||||
assert!(matches!(
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), start),
|
||||
Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if snapshot.targets().len() == 2)
|
||||
Some(Message::DiagramSelection(SelectionEvent::Refuse(_, _)))
|
||||
));
|
||||
graph.interaction.as_mut().unwrap().1 = CanvasTool::Connect;
|
||||
assert!(matches!(
|
||||
@@ -401,99 +401,3 @@ fn mixed_group_at_endpoint_card_refuses_whole_drag_without_hidden_singleton_hand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_delete_modifiers_repeat_and_active_gesture_never_emit_singleton_or_confirmation() {
|
||||
use iced::keyboard::{Event as KeyEvent, Modifiers, key::Named};
|
||||
let f = fixture(Layer::Oa);
|
||||
let graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
let start = point(&graph, f.nodes[0], false);
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::CTRL,
|
||||
Modifiers::ALT,
|
||||
] {
|
||||
for repeat in [false, true] {
|
||||
let iced::Event::Keyboard(mut key) =
|
||||
crate::behavior::canvas_workspace::tests::key(Named::Delete)
|
||||
else {
|
||||
panic!("key")
|
||||
};
|
||||
if let KeyEvent::KeyPressed {
|
||||
modifiers: value,
|
||||
repeat: value_repeat,
|
||||
..
|
||||
} = &mut key
|
||||
{
|
||||
*value = modifiers;
|
||||
*value_repeat = repeat;
|
||||
}
|
||||
let message = send(
|
||||
&graph,
|
||||
&mut Input::default(),
|
||||
canvas::Event::Keyboard(key),
|
||||
start,
|
||||
);
|
||||
if !repeat && (modifiers.is_empty() || modifiers == Modifiers::SHIFT) {
|
||||
assert!(
|
||||
matches!(message, Some(Message::ProcessRemoval(event)) if matches!(&*event, crate::app::process_removal::Event::Begin { snapshot, .. } if Some(&**snapshot) == graph.selection.as_ref()))
|
||||
);
|
||||
} else {
|
||||
assert!(message.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut input = Input::default();
|
||||
press(&graph, &mut input, start);
|
||||
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(Named::Delete)
|
||||
else {
|
||||
panic!("key")
|
||||
};
|
||||
assert!(send(&graph, &mut input, canvas::Event::Keyboard(key), start).is_none());
|
||||
assert!(input.group.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_removal_gesture_lease_retires_on_release_cancel_and_widget_state_drop() {
|
||||
let f = fixture(Layer::Oa);
|
||||
for end in ["release", "escape", "drop", "unfocus"] {
|
||||
let graph = graph(&f, &[local(f.nodes[0]), Target::ProcessLink(f.link)]);
|
||||
let before = graph.removal_input.idle().unwrap();
|
||||
let at = point(&graph, f.nodes[0], false);
|
||||
let mut input = Input::default();
|
||||
assert!(press(&graph, &mut input, at).is_none());
|
||||
assert!(input.group.is_some());
|
||||
assert!(graph.removal_input.idle().is_none());
|
||||
match end {
|
||||
"release" => {
|
||||
release(&graph, &mut input, at);
|
||||
}
|
||||
"escape" => {
|
||||
let iced::Event::Keyboard(key) = crate::behavior::canvas_workspace::tests::key(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
) else {
|
||||
panic!("key")
|
||||
};
|
||||
send(&graph, &mut input, canvas::Event::Keyboard(key), at);
|
||||
}
|
||||
"unfocus" => {
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
at,
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
drop(input);
|
||||
}
|
||||
}
|
||||
assert!(graph.removal_input.idle().is_some());
|
||||
assert_ne!(
|
||||
graph.removal_input.idle(),
|
||||
Some(before),
|
||||
"{end} cannot restore an old Begin generation"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
//! Exact local Process/FunctionalChain removal and definition-level interpretation
|
||||
//! effects. Visibility, source drafts and captured UI lifetime belong to the App.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
#[cfg(test)]
|
||||
use crate::removal_effects::Interpretation;
|
||||
pub(crate) use crate::removal_effects::InterpretationChange;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub(crate) command: Command,
|
||||
pub(crate) removed: BTreeSet<Id>,
|
||||
pub(crate) changed: BTreeSet<Id>,
|
||||
pub(crate) interpretation: Vec<InterpretationChange>,
|
||||
/// Definitions whose authored topology changed, including changes that do
|
||||
/// not switch interpretation. Values are surviving transitive callers.
|
||||
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
|
||||
}
|
||||
|
||||
fn changed_records<T: PartialEq>(
|
||||
before: &BTreeMap<Id, T>,
|
||||
after: &BTreeMap<Id, T>,
|
||||
changed: &mut BTreeSet<Id>,
|
||||
) {
|
||||
changed.extend(
|
||||
before
|
||||
.iter()
|
||||
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, owner: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let element = project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.ok_or("Choose an existing Process or Functional Chain.")?;
|
||||
if targets.is_empty() {
|
||||
return Err("Select at least one local contribution or maintained link to remove.".into());
|
||||
}
|
||||
oa_core::session::validate_candidate(project).map_err(|error| error.to_string())?;
|
||||
let mut listed = BTreeSet::new();
|
||||
for &id in element
|
||||
.steps()
|
||||
.ok_or("This owner has no contribution order.")?
|
||||
{
|
||||
if !is_contribution(project, owner, id)
|
||||
|| project.elements[&id].layer() != element.layer()
|
||||
|| !listed.insert(id)
|
||||
{
|
||||
return Err(
|
||||
"Every process step must name a unique local contribution before removal.".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut selected = BTreeSet::new();
|
||||
let mut occurrences = BTreeSet::new();
|
||||
let mut links = BTreeSet::new();
|
||||
// Admit ALL original typed targets before any dependency normalization.
|
||||
for target in targets {
|
||||
let id = match target {
|
||||
Target::ProcessOccurrence(endpoint) => {
|
||||
if !endpoint.references.is_empty() {
|
||||
return Err("Open the referenced definition to remove its contents.".into());
|
||||
}
|
||||
if !listed.contains(&endpoint.occurrence) {
|
||||
return Err("Every selected contribution must be listed directly in this process.".into());
|
||||
}
|
||||
occurrences.insert(endpoint.occurrence);
|
||||
endpoint.occurrence
|
||||
}
|
||||
Target::ProcessLink(id) => {
|
||||
let relation = project.relations.get(id).ok_or("A selected maintained link no longer exists.")?;
|
||||
let local = match &relation.body {
|
||||
RelationBody::SequenceLink { source, target, .. } => listed.contains(source) && listed.contains(target),
|
||||
RelationBody::ContextualLink { owner: actual, .. } => *actual == owner,
|
||||
_ => false,
|
||||
};
|
||||
if !local {
|
||||
return Err("Every selected link must be maintained locally by this process, with listed local endpoints for sequence links.".into());
|
||||
}
|
||||
links.insert(*id);
|
||||
*id
|
||||
}
|
||||
_ => return Err("Remove only local process contributions and maintained links together; referenced preview links are read-only here.".into()),
|
||||
};
|
||||
if !selected.insert(id) {
|
||||
return Err("Every selected process subject must be unique.".into());
|
||||
}
|
||||
}
|
||||
// Contribution kinds cannot contain one another; only admitted links can be
|
||||
// covered by an occurrence cascade. Ancestry alone misses incident links.
|
||||
let covered: BTreeSet<_> = occurrences
|
||||
.iter()
|
||||
.flat_map(|id| oa_core::references::deletion_impact(project, *id))
|
||||
.collect();
|
||||
let command = Command::Batch(
|
||||
occurrences
|
||||
.into_iter()
|
||||
.chain(links.into_iter().filter(|id| !covered.contains(id)))
|
||||
.map(Command::Delete)
|
||||
.collect(),
|
||||
);
|
||||
let candidate = execute_batch(project, std::slice::from_ref(&command))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
let retained: BTreeSet<_> = candidate
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
if !retained.is_subset(&original) {
|
||||
return Err("Removal unexpectedly created model identities; no change was applied.".into());
|
||||
}
|
||||
let removed: BTreeSet<_> = original.difference(&retained).copied().collect();
|
||||
if !selected.is_subset(&removed) {
|
||||
return Err("The complete selected set could not be removed.".into());
|
||||
}
|
||||
let mut changed = BTreeSet::new();
|
||||
changed_records(&project.elements, &candidate.elements, &mut changed);
|
||||
changed_records(&project.relations, &candidate.relations, &mut changed);
|
||||
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
|
||||
changed_records(
|
||||
&project.artifact_links,
|
||||
&candidate.artifact_links,
|
||||
&mut changed,
|
||||
);
|
||||
changed_records(&project.traces, &candidate.traces, &mut changed);
|
||||
for (&id, diagram) in &project.diagrams {
|
||||
if let Some(after) = candidate.diagrams.get(&id) {
|
||||
changed_records(&diagram.nodes, &after.nodes, &mut changed);
|
||||
}
|
||||
}
|
||||
let crate::removal_effects::DerivedEffects {
|
||||
interpretation,
|
||||
affected_uses,
|
||||
} = crate::removal_effects::analyze(project, &candidate, &removed, &changed);
|
||||
Ok(Plan {
|
||||
command,
|
||||
removed,
|
||||
changed,
|
||||
interpretation,
|
||||
affected_uses,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,909 +0,0 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{MessageKind, source_project::ProjectLimits};
|
||||
|
||||
fn endpoint(id: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: id,
|
||||
}
|
||||
}
|
||||
|
||||
fn local(id: Id) -> Target {
|
||||
Target::ProcessOccurrence(endpoint(id))
|
||||
}
|
||||
|
||||
fn add(project: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
|
||||
let element = occurrence(project, owner, name, body);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn action(project: &mut Project, owner: Id, name: &str, work: Id) -> Id {
|
||||
add(
|
||||
project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: Some(work),
|
||||
lifeline: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn exchange_step(project: &mut Project, owner: Id, name: &str, exchange: Id) -> Id {
|
||||
add(
|
||||
project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn sequence(project: &mut Project, layer: Layer, name: &str) -> Id {
|
||||
project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Process
|
||||
} else {
|
||||
Kind::FunctionalChain
|
||||
},
|
||||
layer,
|
||||
name,
|
||||
None,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn reuse(project: &mut Project, owner: Id, name: &str, definition: Id) -> Id {
|
||||
add(
|
||||
project,
|
||||
owner,
|
||||
name,
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(definition),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn maintained(project: &mut Project, source: Id, target: Id) -> Id {
|
||||
let Command::CreateRelation(relation) = link(project, source, target, "authored guard") else {
|
||||
panic!()
|
||||
};
|
||||
let id = relation.id;
|
||||
project.relations.insert(id, relation);
|
||||
id
|
||||
}
|
||||
|
||||
fn contextual(
|
||||
project: &mut Project,
|
||||
owner: Id,
|
||||
source: OccurrenceEndpoint,
|
||||
target: OccurrenceEndpoint,
|
||||
purpose: ContextualLinkPurpose,
|
||||
) -> Id {
|
||||
let Command::CreateRelation(mut relation) =
|
||||
link(project, source.occurrence, target.occurrence, "")
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
let id = relation.id;
|
||||
relation.body = RelationBody::ContextualLink {
|
||||
owner,
|
||||
source,
|
||||
target,
|
||||
purpose,
|
||||
};
|
||||
project.relations.insert(id, relation);
|
||||
id
|
||||
}
|
||||
|
||||
fn precedence() -> ContextualLinkPurpose {
|
||||
ContextualLinkPurpose::Precedence {
|
||||
guard: "caller guard".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn qualified(references: &[Id], occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: references.to_vec(),
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn interpretation(
|
||||
local: Option<Vec<Id>>,
|
||||
expanded: Option<Vec<Id>>,
|
||||
contextual: bool,
|
||||
) -> Interpretation {
|
||||
Interpretation {
|
||||
local_order: local,
|
||||
expanded_order: expanded,
|
||||
contextual_analysis: contextual,
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_without(project: &Project, owner: Id, elements: &[Id], relations: &[Id]) -> Project {
|
||||
let mut expected = project.clone();
|
||||
for id in elements {
|
||||
expected.elements.remove(id);
|
||||
}
|
||||
for id in relations {
|
||||
expected.relations.remove(id);
|
||||
}
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| !elements.contains(id));
|
||||
expected
|
||||
}
|
||||
|
||||
fn assert_history(project: &Project, expected: &Project, command: Command) {
|
||||
let mut doc = Document::from_project(project.clone(), ProjectLimits::default()).unwrap();
|
||||
let original = doc.bundle().clone();
|
||||
doc.execute(command).unwrap();
|
||||
assert_eq!(doc.project(), expected);
|
||||
let after = doc.bundle().clone();
|
||||
let reopened = Document::from_bundle(after.clone(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), expected);
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.project(), project);
|
||||
assert_eq!(doc.bundle(), &original);
|
||||
assert!(!doc.can_undo());
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.project(), expected);
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
assert!(!doc.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_mixed_cards_preserve_sources_definitions_and_other_uses() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let definition = sequence(&mut p, layer, "Reusable");
|
||||
let leaf = action(&mut p, definition, "Leaf", f.work[0]);
|
||||
let a = action(&mut p, f.owner, "Action", f.work[0]);
|
||||
let e = exchange_step(&mut p, f.owner, "Exchange", f.exchange);
|
||||
let c = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Control",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let r = reuse(&mut p, f.owner, "First use", definition);
|
||||
let r2 = reuse(&mut p, f.owner, "Preserved use", definition);
|
||||
let plan = prepare(&p, f.owner, &[local(r), local(c), local(e), local(a)]).unwrap();
|
||||
assert_eq!(plan.removed, BTreeSet::from([a, e, c, r]));
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
|
||||
assert!(plan.interpretation.is_empty());
|
||||
let expected = expected_without(&p, f.owner, &[a, e, c, r], &[]);
|
||||
assert_eq!(expected.elements[&f.owner].steps().unwrap(), &[r2]);
|
||||
assert!(expected.contains(leaf));
|
||||
assert_history(&p, &expected, plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_dependent_selected_links_are_normalized_after_whole_set_admission() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let [a, b, c] = f.nodes;
|
||||
let parallel = maintained(&mut p, a, b);
|
||||
let adjacent = maintained(&mut p, b, c);
|
||||
let context = contextual(
|
||||
&mut p,
|
||||
f.owner,
|
||||
endpoint(a),
|
||||
endpoint(b),
|
||||
ContextualLinkPurpose::Exchange {
|
||||
exchange: f.exchange,
|
||||
},
|
||||
);
|
||||
let targets = vec![
|
||||
Target::ProcessLink(f.link),
|
||||
local(a),
|
||||
Target::ProcessLink(parallel),
|
||||
local(b),
|
||||
Target::ProcessLink(context),
|
||||
];
|
||||
let first = prepare(&p, f.owner, &targets).unwrap();
|
||||
let mut reverse = targets.clone();
|
||||
reverse.reverse();
|
||||
let second = prepare(&p, f.owner, &reverse).unwrap();
|
||||
assert_eq!(first.command, second.command);
|
||||
assert_eq!(
|
||||
first.removed,
|
||||
BTreeSet::from([a, b, f.link, parallel, adjacent, context])
|
||||
);
|
||||
assert_eq!(first.changed, BTreeSet::from([f.owner]));
|
||||
assert_eq!(
|
||||
first.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, true),
|
||||
after: interpretation(None, None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
let Command::Batch(commands) = &first.command else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
2,
|
||||
"All selected links are covered by admitted card roots"
|
||||
);
|
||||
let expected =
|
||||
expected_without(&p, f.owner, &[a, b], &[f.link, parallel, adjacent, context]);
|
||||
assert_history(&p, &expected, first.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_refuses_every_invalid_original_typed_member() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let other = sequence(&mut p, layer, "Other");
|
||||
let foreign = action(&mut p, other, "Foreign", f.work[0]);
|
||||
let foreign_other = action(&mut p, other, "Other foreign", f.work[0]);
|
||||
let unlisted = action(&mut p, f.owner, "Owned but unlisted", f.work[0]);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != unlisted);
|
||||
let unlisted_link = maintained(&mut p, unlisted, f.nodes[0]);
|
||||
let foreign_context = contextual(
|
||||
&mut p,
|
||||
other,
|
||||
endpoint(foreign),
|
||||
endpoint(foreign_other),
|
||||
ContextualLinkPurpose::CommonWork { work: f.work[0] },
|
||||
);
|
||||
// Use two separate qualified paths with the same leaf: neither can be
|
||||
// silently reduced to that leaf or to an outer reference occurrence.
|
||||
let r1 = reuse(&mut p, f.owner, "Use one", other);
|
||||
let r2 = reuse(&mut p, f.owner, "Use two", other);
|
||||
let missing = Id::new_v4();
|
||||
let bad = vec![
|
||||
Target::Whole,
|
||||
Target::Placement(f.nodes[0]),
|
||||
Target::Port {
|
||||
port: f.work[0],
|
||||
placement: f.nodes[0],
|
||||
},
|
||||
Target::Edge(f.link),
|
||||
Target::StateRow {
|
||||
placement: f.nodes[0],
|
||||
relation: f.link,
|
||||
},
|
||||
Target::AllocationGrouping {
|
||||
relation: f.link,
|
||||
work_placement: f.nodes[0],
|
||||
performer_placement: f.nodes[1],
|
||||
},
|
||||
Target::ScenarioObject(f.nodes[0]),
|
||||
Target::ProcessPreviewLink {
|
||||
relation: f.link,
|
||||
source: endpoint(f.nodes[0]),
|
||||
target: endpoint(f.nodes[1]),
|
||||
},
|
||||
Target::ProcessOccurrence(qualified(&[r1], foreign)),
|
||||
Target::ProcessOccurrence(qualified(&[r2], foreign)),
|
||||
local(foreign),
|
||||
local(unlisted),
|
||||
local(missing),
|
||||
local(f.work[0]),
|
||||
Target::ProcessLink(unlisted_link),
|
||||
Target::ProcessLink(foreign_context),
|
||||
Target::ProcessLink(f.exchange),
|
||||
Target::ProcessLink(missing),
|
||||
Target::ProcessLink(f.nodes[0]),
|
||||
];
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let source = oa_core::language::format(&p);
|
||||
for target in bad {
|
||||
assert!(
|
||||
prepare(&p, f.owner, &[local(f.nodes[0]), target.clone()]).is_err(),
|
||||
"{target:?}"
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[target, local(f.nodes[0])]).is_err());
|
||||
}
|
||||
assert!(prepare(&p, f.owner, &[]).is_err());
|
||||
assert!(prepare(&p, f.owner, &[local(f.nodes[0]), local(f.nodes[0])]).is_err());
|
||||
assert!(
|
||||
prepare(
|
||||
&p,
|
||||
f.owner,
|
||||
&[Target::ProcessLink(f.link), Target::ProcessLink(f.link)]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(prepare(&p, f.work[0], &[local(f.nodes[0])]).is_err());
|
||||
assert!(prepare(&p, missing, &[local(f.nodes[0])]).is_err());
|
||||
assert_eq!(oa_core::language::format(&p), source);
|
||||
let mut duplicate_steps = p.clone();
|
||||
duplicate_steps
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(f.nodes[0]);
|
||||
assert!(prepare(&duplicate_steps, f.owner, &[local(f.nodes[1])]).is_err());
|
||||
let mut wrong_parent = p.clone();
|
||||
wrong_parent.elements.get_mut(&f.nodes[0]).unwrap().parent = Some(other);
|
||||
assert!(prepare(&wrong_parent, f.owner, &[local(f.nodes[1])]).is_err());
|
||||
let mut missing_step = p.clone();
|
||||
missing_step
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(missing);
|
||||
assert!(prepare(&missing_step, f.owner, &[local(f.nodes[1])]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_last_markers_and_legacy_interior_report_literal_orders() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let a = action(&mut p, f.owner, "Marker", f.work[0]);
|
||||
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
|
||||
let c = add(
|
||||
&mut p,
|
||||
f.owner,
|
||||
"Control",
|
||||
OccurrenceBody::Control {
|
||||
kind: ControlKind::Choice,
|
||||
},
|
||||
);
|
||||
let e2 = exchange_step(&mut p, f.owner, "Middle", f.exchange);
|
||||
let e3 = exchange_step(&mut p, f.owner, "Last", f.exchange);
|
||||
let caller = sequence(&mut p, layer, "Caller");
|
||||
reuse(&mut p, caller, "Reference", f.owner);
|
||||
let outer = sequence(&mut p, layer, "Outer");
|
||||
reuse(&mut p, outer, "Transitive reference", caller);
|
||||
let plan = prepare(&p, f.owner, &[local(c), local(a)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, false),
|
||||
after: interpretation(Some(vec![e1, e2, e3]), Some(vec![e1, e2, e3]), false),
|
||||
callers: BTreeSet::from([caller, outer]),
|
||||
}]
|
||||
);
|
||||
let expected = expected_without(&p, f.owner, &[a, c], &[]);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
let bridge = prepare(&expected, f.owner, &[local(e2)]).unwrap();
|
||||
assert_eq!(
|
||||
bridge.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2, e3]), Some(vec![e1, e2, e3]), false),
|
||||
after: interpretation(Some(vec![e1, e3]), Some(vec![e1, e3]), false),
|
||||
callers: BTreeSet::from([caller, outer]),
|
||||
}]
|
||||
);
|
||||
assert_eq!(bridge.removed, BTreeSet::from([e2]));
|
||||
assert_history(
|
||||
&expected,
|
||||
&expected_without(&expected, f.owner, &[e2], &[]),
|
||||
bridge.command,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_last_link_and_zero_or_one_exchange_have_no_invented_objects() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
|
||||
let e2 = exchange_step(&mut p, f.owner, "Second", next);
|
||||
let link = maintained(&mut p, e1, e2);
|
||||
let plan = prepare(&p, f.owner, &[Target::ProcessLink(link)]).unwrap();
|
||||
assert_eq!(plan.removed, BTreeSet::from([link]));
|
||||
assert!(plan.changed.is_empty());
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, false),
|
||||
after: interpretation(Some(vec![e1, e2]), Some(vec![e1, e2]), false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(
|
||||
&p,
|
||||
&expected_without(&p, f.owner, &[], &[link]),
|
||||
plan.command,
|
||||
);
|
||||
for remaining in [vec![], vec![e1]] {
|
||||
let mut model = expected_without(&p, f.owner, &[e2], &[link]);
|
||||
if remaining.is_empty() {
|
||||
model = expected_without(&model, f.owner, &[e1], &[]);
|
||||
}
|
||||
let marker = action(&mut model, f.owner, "Marker", f.work[0]);
|
||||
let plan = prepare(&model, f.owner, &[local(marker)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(None, None, false),
|
||||
after: interpretation(Some(remaining.clone()), Some(remaining), false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(
|
||||
&model,
|
||||
&expected_without(&model, f.owner, &[marker], &[]),
|
||||
plan.command,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_global_contextual_dependencies_and_caller_modes_are_separate_from_record_deltas()
|
||||
{
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let caller = sequence(&mut p, layer, "Caller");
|
||||
let r1 = reuse(&mut p, caller, "Use one", f.owner);
|
||||
let r2 = reuse(&mut p, caller, "Use two", f.owner);
|
||||
let outer = sequence(&mut p, layer, "Outer");
|
||||
let outer_use = reuse(&mut p, outer, "Outer use", caller);
|
||||
let caller_link = contextual(
|
||||
&mut p,
|
||||
caller,
|
||||
qualified(&[r1], f.nodes[0]),
|
||||
qualified(&[r2], f.nodes[1]),
|
||||
precedence(),
|
||||
);
|
||||
let outer_link = contextual(
|
||||
&mut p,
|
||||
outer,
|
||||
qualified(&[outer_use, r1], f.nodes[0]),
|
||||
qualified(&[outer_use, r2], f.nodes[1]),
|
||||
precedence(),
|
||||
);
|
||||
let top = sequence(&mut p, layer, "Top");
|
||||
reuse(&mut p, top, "Top use", outer);
|
||||
let plan = prepare(&p, f.owner, &[local(f.nodes[0])]).unwrap();
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
BTreeSet::from([f.nodes[0], f.link, caller_link, outer_link])
|
||||
);
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
|
||||
let by_owner: BTreeMap<_, _> = plan
|
||||
.interpretation
|
||||
.iter()
|
||||
.map(|change| (change.owner, change))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
by_owner.keys().copied().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([caller, outer, top])
|
||||
);
|
||||
for id in [caller, outer, top] {
|
||||
assert_eq!(by_owner[&id].before, interpretation(None, None, true));
|
||||
assert_eq!(by_owner[&id].after, interpretation(None, None, false));
|
||||
}
|
||||
assert_eq!(by_owner[&caller].callers, BTreeSet::from([outer, top]));
|
||||
assert_eq!(by_owner[&outer].callers, BTreeSet::from([top]));
|
||||
assert!(by_owner[&top].callers.is_empty());
|
||||
assert_eq!(
|
||||
plan.affected_uses,
|
||||
BTreeMap::from([
|
||||
(f.owner, BTreeSet::from([caller, outer, top])),
|
||||
(caller, BTreeSet::from([outer, top])),
|
||||
(outer, BTreeSet::from([top])),
|
||||
])
|
||||
);
|
||||
let expected = expected_without(
|
||||
&p,
|
||||
f.owner,
|
||||
&[f.nodes[0]],
|
||||
&[f.link, caller_link, outer_link],
|
||||
);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
// Removing an outer reuse removes only that use and paths through it.
|
||||
let outer_plan =
|
||||
prepare(&p, caller, &[local(r1), Target::ProcessLink(caller_link)]).unwrap();
|
||||
assert_eq!(
|
||||
outer_plan.removed,
|
||||
BTreeSet::from([r1, caller_link, outer_link])
|
||||
);
|
||||
let expected = expected_without(&p, caller, &[r1], &[caller_link, outer_link]);
|
||||
assert_history(&p, &expected, outer_plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_qualified_local_relations_preserve_every_purpose_source() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for purpose_kind in 0..3 {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let owner = sequence(&mut p, layer, "Assembly");
|
||||
let r1 = reuse(&mut p, owner, "First", f.owner);
|
||||
let r2 = reuse(&mut p, owner, "Second", f.owner);
|
||||
let (target, purpose) = match purpose_kind {
|
||||
0 => (f.nodes[1], precedence()),
|
||||
1 => (
|
||||
f.nodes[1],
|
||||
ContextualLinkPurpose::Exchange {
|
||||
exchange: f.exchange,
|
||||
},
|
||||
),
|
||||
_ => (
|
||||
f.nodes[0],
|
||||
ContextualLinkPurpose::CommonWork { work: f.work[0] },
|
||||
),
|
||||
};
|
||||
let relation = contextual(
|
||||
&mut p,
|
||||
owner,
|
||||
qualified(&[r1], f.nodes[0]),
|
||||
qualified(&[r2], target),
|
||||
purpose,
|
||||
);
|
||||
let plan = prepare(&p, owner, &[Target::ProcessLink(relation)]).unwrap();
|
||||
assert_eq!(plan.removed, BTreeSet::from([relation]));
|
||||
assert!(plan.changed.is_empty());
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner,
|
||||
before: interpretation(None, None, true),
|
||||
after: interpretation(None, None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(
|
||||
&p,
|
||||
&expected_without(&p, owner, &[], &[relation]),
|
||||
plan.command,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_predicate_discriminators_include_contextual_only_and_unlisted_contributions()
|
||||
{
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, _) = super::super::direct_tests::empty_exchange_fixture(layer);
|
||||
let mut p = f.p;
|
||||
let e1 = exchange_step(&mut p, f.owner, "First", f.exchange);
|
||||
let e2 = exchange_step(&mut p, f.owner, "Second", f.exchange);
|
||||
// Core-valid incomplete source: desktop lists only exchanges, while the
|
||||
// index includes two unlisted actions and their CommonWork relation.
|
||||
let a1 = action(&mut p, f.owner, "Unlisted one", f.work[0]);
|
||||
let a2 = action(&mut p, f.owner, "Unlisted two", f.work[0]);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != a1 && *id != a2);
|
||||
let relation = contextual(
|
||||
&mut p,
|
||||
f.owner,
|
||||
endpoint(a1),
|
||||
endpoint(a2),
|
||||
ContextualLinkPurpose::CommonWork { work: f.work[0] },
|
||||
);
|
||||
let plan = prepare(&p, f.owner, &[Target::ProcessLink(relation)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2]), None, true),
|
||||
after: interpretation(Some(vec![e1, e2]), None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
let mut p = expected_without(&p, f.owner, &[a1, a2], &[relation]);
|
||||
let unlisted = action(&mut p, f.owner, "Unlisted marker", f.work[0]);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != unlisted);
|
||||
let plan = prepare(&p, f.owner, &[local(e1)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2]), None, false),
|
||||
after: interpretation(Some(vec![e2]), None, false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert!(prepare(&p, f.owner, &[local(unlisted), local(e1)]).is_err());
|
||||
let p = expected_without(&p, f.owner, &[unlisted], &[]);
|
||||
let mut p = p;
|
||||
let extra = exchange_step(&mut p, f.owner, "Unlisted exchange", f.exchange);
|
||||
p.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != extra);
|
||||
let plan = prepare(&p, f.owner, &[local(e1)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.interpretation,
|
||||
vec![InterpretationChange {
|
||||
owner: f.owner,
|
||||
before: interpretation(Some(vec![e1, e2]), Some(vec![e1, e2, extra]), false),
|
||||
after: interpretation(Some(vec![e2]), Some(vec![e2, extra]), false),
|
||||
callers: BTreeSet::new(),
|
||||
}]
|
||||
);
|
||||
assert_history(&p, &expected_without(&p, f.owner, &[e1], &[]), plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_explicit_topology_still_reports_transitive_reused_effects() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
maintained(&mut p, f.nodes[1], f.nodes[2]);
|
||||
let caller = sequence(&mut p, layer, "Caller");
|
||||
reuse(&mut p, caller, "First use", f.owner);
|
||||
reuse(&mut p, caller, "Repeated use", f.owner);
|
||||
let outer = sequence(&mut p, layer, "Outer");
|
||||
reuse(&mut p, outer, "Transitive use", caller);
|
||||
for target in [local(f.nodes[0]), Target::ProcessLink(f.link)] {
|
||||
let plan = prepare(&p, f.owner, &[target]).unwrap();
|
||||
assert!(
|
||||
plan.interpretation.is_empty(),
|
||||
"Explicit modes remain unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.affected_uses,
|
||||
BTreeMap::from([(f.owner, BTreeSet::from([caller, outer]))])
|
||||
);
|
||||
assert!(!plan.changed.contains(&caller));
|
||||
assert!(!plan.changed.contains(&outer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_mixed_covered_and_independent_selected_links_delete_each_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let [a, b, c] = f.nodes;
|
||||
let independent = maintained(&mut p, b, c);
|
||||
let targets = [
|
||||
Target::ProcessLink(independent),
|
||||
Target::ProcessLink(f.link),
|
||||
local(a),
|
||||
];
|
||||
let plan = prepare(&p, f.owner, &targets).unwrap();
|
||||
let reverse = prepare(&p, f.owner, &targets.into_iter().rev().collect::<Vec<_>>()).unwrap();
|
||||
assert_eq!(plan.command, reverse.command);
|
||||
assert_eq!(plan.removed, BTreeSet::from([a, f.link, independent]));
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.owner]));
|
||||
let Command::Batch(commands) = &plan.command else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
commands,
|
||||
&[Command::Delete(a), Command::Delete(independent)]
|
||||
);
|
||||
assert!(plan.interpretation.is_empty());
|
||||
let expected = expected_without(&p, f.owner, &[a], &[f.link, independent]);
|
||||
assert_eq!(expected.elements[&f.owner].steps().unwrap(), &[b, c]);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_removal_exact_attachments_traces_placements_and_diagram_impact() {
|
||||
use oa_core::{ArtifactLink, ArtifactRelation, EdgeRoute, Position, Trace, TraceKind};
|
||||
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = super::super::direct_tests::fixture(layer);
|
||||
let mut p = f.p;
|
||||
let [a, b, _] = f.nodes;
|
||||
let overview = p.ensure_diagram(layer);
|
||||
// Overview explicitly includes Occurrence in ProjectionKind::includes;
|
||||
// saved placement validation admits these same-layer semantic elements.
|
||||
assert!(p.diagrams[&overview].projection.includes(Kind::Occurrence));
|
||||
let node = p.place(overview, a, Position { x: 100., y: 100. });
|
||||
let kept_node = p.place(overview, b, Position { x: 400., y: 100. });
|
||||
p.diagrams
|
||||
.get_mut(&overview)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: f.link,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 300., y: 80. }],
|
||||
label: None,
|
||||
});
|
||||
let other_view = Id::new_v4();
|
||||
let mut view = p.diagrams[&overview].clone();
|
||||
view.id = other_view;
|
||||
view.alias = p.fresh_alias("second_overview", "view", other_view);
|
||||
view.name = "Second overview".into();
|
||||
view.nodes.clear();
|
||||
view.routes.clear();
|
||||
p.diagrams.insert(other_view, view);
|
||||
let other_node = p.place(other_view, a, Position { x: 220., y: 160. });
|
||||
let opposite = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let counterpart = p
|
||||
.add_element(
|
||||
if opposite == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
},
|
||||
opposite,
|
||||
"Preserved counterpart",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let unrelated_view = p.ensure_diagram(opposite);
|
||||
p.place(unrelated_view, counterpart, Position { x: 50., y: 75. });
|
||||
let traces: [Id; 2] = [a, b].map(|subject| {
|
||||
let id = Id::new_v4();
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: p.fresh_alias("manual_trace", "trace", id),
|
||||
name: "Manual correspondence".into(),
|
||||
description: "Preserve counterpart".into(),
|
||||
oa: if layer == Layer::Oa {
|
||||
subject
|
||||
} else {
|
||||
counterpart
|
||||
},
|
||||
sa: if layer == Layer::Sa {
|
||||
subject
|
||||
} else {
|
||||
counterpart
|
||||
},
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
// Include an attachment owned by the dependent placement: core impact
|
||||
// must follow the fixed point rather than only direct semantic subjects.
|
||||
let artifacts: [Id; 4] = [a, node, f.link, b].map(|owner| {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias("evidence", "artifact", id),
|
||||
name: "Evidence".into(),
|
||||
description: "Authored evidence".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "evidence.txt".into(),
|
||||
revision: Some("v1".into()),
|
||||
fragment: Some("review".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
let constraint = p
|
||||
.add_element(Kind::Constraint, layer, "Surviving constraint", None)
|
||||
.unwrap();
|
||||
*p.elements
|
||||
.get_mut(&constraint)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Constraint {
|
||||
expression: "review".into(),
|
||||
constrained: vec![a, b],
|
||||
};
|
||||
oa_core::session::validate_candidate(&p).unwrap();
|
||||
let plan = prepare(&p, f.owner, &[local(a)]).unwrap();
|
||||
assert_eq!(
|
||||
plan.removed,
|
||||
BTreeSet::from([
|
||||
a,
|
||||
f.link,
|
||||
node,
|
||||
other_node,
|
||||
traces[0],
|
||||
artifacts[0],
|
||||
artifacts[1],
|
||||
artifacts[2],
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changed,
|
||||
BTreeSet::from([f.owner, overview, other_view, constraint])
|
||||
);
|
||||
assert_eq!(
|
||||
plan.affected_uses,
|
||||
BTreeMap::from([(f.owner, BTreeSet::new())])
|
||||
);
|
||||
let mut expected = expected_without(&p, f.owner, &[a], &[f.link]);
|
||||
expected.traces.remove(&traces[0]);
|
||||
for id in &artifacts[..3] {
|
||||
expected.artifact_links.remove(id);
|
||||
}
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&overview)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&node);
|
||||
expected.diagrams.get_mut(&overview).unwrap().routes.clear();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&other_view)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&other_node);
|
||||
let Some(SharedBody::Constraint { constrained, .. }) =
|
||||
expected.elements.get_mut(&constraint).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*constrained = vec![b];
|
||||
assert_eq!(expected.elements[&counterpart], p.elements[&counterpart]);
|
||||
assert_eq!(
|
||||
expected.diagrams[&overview].nodes[&kept_node],
|
||||
p.diagrams[&overview].nodes[&kept_node]
|
||||
);
|
||||
assert_eq!(
|
||||
expected.diagrams[&unrelated_view],
|
||||
p.diagrams[&unrelated_view]
|
||||
);
|
||||
assert_eq!(expected.traces[&traces[1]], p.traces[&traces[1]]);
|
||||
assert_eq!(
|
||||
expected.artifact_links[&artifacts[3]],
|
||||
p.artifact_links[&artifacts[3]]
|
||||
);
|
||||
assert_history(&p, &expected, plan.command);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
&state.contextual_expanded,
|
||||
);
|
||||
graph.selection = app.multiselect_snapshot();
|
||||
graph.removal_input = state.removal_input.clone();
|
||||
graph.selected_preview = state
|
||||
.qualified_selection(p, owner)
|
||||
.filter(|address| {
|
||||
@@ -575,15 +574,6 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| !s.targets().is_empty()) {
|
||||
if snapshot.targets().len() > 1 {
|
||||
controls = controls.push(
|
||||
button("Remove selected…").on_press_maybe(
|
||||
app.process_removal_enabled()
|
||||
.then(|| crate::app::process_removal::begin(app))
|
||||
.flatten(),
|
||||
),
|
||||
);
|
||||
}
|
||||
controls = controls.push(button("Clear selection").on_press_maybe(
|
||||
enabled.then(|| Message::DiagramSelection(SelectionEvent::Clear(snapshot.context()))),
|
||||
));
|
||||
|
||||
@@ -39,6 +39,24 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contextual drawing follows the visible semantic source, not the palette layer.
|
||||
/// The caller separately validates the menu/context lifetime and pending drafts.
|
||||
pub fn can_begin(project: &Project, diagram: Id, tool: Tool, source: Id) -> bool {
|
||||
let Some(diagram) = project.diagrams.get(&diagram) else {
|
||||
return false;
|
||||
};
|
||||
let Some(element) = project.elements.get(&source) else {
|
||||
return false;
|
||||
};
|
||||
diagram.projection.is_architecture()
|
||||
&& (element.layer() == diagram.layer
|
||||
|| diagram.projection == oa_core::ProjectionKind::Traceability)
|
||||
&& Tool::for_layer(element.layer()).contains(&tool)
|
||||
&& tool.can_start(project, source)
|
||||
&& (tool != Tool::Allocation || tool.available_in(element.layer(), diagram.projection))
|
||||
&& visible(&oa_core::projection::project_view(project, diagram.id)).any(|id| id == source)
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
view.nodes
|
||||
.iter()
|
||||
|
||||
@@ -43,7 +43,6 @@ pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_gesture;
|
||||
pub(crate) mod group_removal;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
@@ -53,7 +52,6 @@ pub use work_drag::WorkFeedback;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub removal_input: &'a crate::removal_input::Lifetime,
|
||||
pub selection: Option<&'a crate::diagram_selection::Selection>,
|
||||
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
||||
pub project: &'a Project,
|
||||
@@ -73,8 +71,6 @@ pub struct DiagramCanvas<'a> {
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
removal_lease: Option<crate::removal_input::Lease>,
|
||||
removal_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<multiselect::Press>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
@@ -610,8 +606,9 @@ impl DiagramCanvas<'_> {
|
||||
});
|
||||
}
|
||||
}
|
||||
impl DiagramCanvas<'_> {
|
||||
fn update_input(
|
||||
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
@@ -996,7 +993,10 @@ impl DiagramCanvas<'_> {
|
||||
world,
|
||||
));
|
||||
}
|
||||
return Some(Action::publish(Message::Select(port.element)).and_capture());
|
||||
return Some(
|
||||
Action::publish(Message::CanvasSelect(self.context, port.element))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if let Some(id) = self.hit(view, p) {
|
||||
let position = self
|
||||
@@ -1013,7 +1013,7 @@ impl DiagramCanvas<'_> {
|
||||
.hit_label(&scene.routed, p)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, p))
|
||||
{
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
|
||||
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
|
||||
if id == self.selected
|
||||
&& self.editable
|
||||
@@ -1029,7 +1029,7 @@ impl DiagramCanvas<'_> {
|
||||
self.begin_route(state, world, Handle::Bend(index), route);
|
||||
Some(Action::capture())
|
||||
} else {
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
|
||||
}
|
||||
} else {
|
||||
state.pan_start = Some((mouse::Button::Left, p));
|
||||
@@ -1246,67 +1246,6 @@ impl DiagramCanvas<'_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
type State = State;
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.group.is_some()
|
||||
|| state.work.is_some()
|
||||
|| state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
|| state.route_drag.is_some()
|
||||
|| state.connection_press.is_some()
|
||||
|| state.port_drag.is_some(),
|
||||
);
|
||||
self.removal_input.observe(event);
|
||||
let viewport = (self.zoom, self.pan, bounds);
|
||||
if state
|
||||
.removal_viewport
|
||||
.is_some_and(|previous| previous != viewport)
|
||||
{
|
||||
self.removal_input.revoke_idle();
|
||||
}
|
||||
state.removal_viewport = Some(viewport);
|
||||
let action = self.update_input(state, event, bounds, cursor);
|
||||
if matches!(
|
||||
event,
|
||||
canvas::Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
) {
|
||||
// The viewport can synthesize Unfocused when it takes camera input.
|
||||
// Never leave a private drag or its lease waiting for a lost release.
|
||||
state.group = None;
|
||||
state.work = None;
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.pan_start = None;
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
state.connection_press = None;
|
||||
state.connection_hover = None;
|
||||
state.port_drag = None;
|
||||
state.pointer = None;
|
||||
}
|
||||
self.removal_input.sync(
|
||||
&mut state.removal_lease,
|
||||
state.group.is_some()
|
||||
|| state.work.is_some()
|
||||
|| state.drag.is_some()
|
||||
|| state.pan_start.is_some()
|
||||
|| state.route_drag.is_some()
|
||||
|| state.connection_press.is_some()
|
||||
|| state.port_drag.is_some(),
|
||||
);
|
||||
action
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
state: &State,
|
||||
@@ -2392,7 +2331,6 @@ mod tests {
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2471,7 +2409,6 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2507,7 +2444,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
|
||||
assert!(matches!(selected,Some(Message::CanvasSelect(_, id)) if id==port.element));
|
||||
let request = canvas.context_request(
|
||||
&state,
|
||||
click,
|
||||
@@ -2601,7 +2538,6 @@ mod tests {
|
||||
};
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2778,7 +2714,6 @@ mod tests {
|
||||
for zoom in [0.5, 1., 1.5] {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2858,7 +2793,6 @@ mod tests {
|
||||
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -2959,7 +2893,6 @@ mod tests {
|
||||
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3091,7 +3024,6 @@ mod tests {
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3125,7 +3057,7 @@ mod tests {
|
||||
let mut state = State::default();
|
||||
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::CanvasSelect(_, id)) if id==edge)
|
||||
);
|
||||
assert!(
|
||||
state.route_drag.is_none(),
|
||||
@@ -3149,7 +3081,6 @@ mod tests {
|
||||
offset: Position { x: 0., y: -160. },
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3176,7 +3107,7 @@ mod tests {
|
||||
let center = scene.routed.edges[&edge].label.center;
|
||||
let screen = point(center) + canvas.pan;
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge)
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::CanvasSelect(_, id)) if id==edge)
|
||||
);
|
||||
canvas.selected = edge;
|
||||
pointer(
|
||||
@@ -3227,7 +3158,6 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
@@ -3264,7 +3194,6 @@ mod tests {
|
||||
routed.edges.insert(duplicate, duplicate_route);
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
removal_input: &crate::removal_input::Lifetime::default(),
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
|
||||
@@ -98,7 +98,6 @@ fn fixture(control: Control, cover: Option<Kind>) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
|
||||
@@ -34,7 +34,6 @@ fn fixture() -> App {
|
||||
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
|
||||
@@ -206,16 +206,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
// The focus task runs after the native batch. Retain focus for its
|
||||
// traversal count, but reject later keys in this same batch now.
|
||||
state.traversing = true;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&InputEvent::Window(iced::window::Event::Unfocused),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
shell.publish((self.traverse)(modifiers.shift()));
|
||||
shell.capture_event();
|
||||
return;
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
//! Pure, whole-selection Delete from model planning for saved architecture and
|
||||
//! State views. UI lifetime, drafts and confirmation remain the caller's concern.
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::{
|
||||
Command, Id, Kind, Project, execute_batch, projection, references, validate_candidate,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Plan {
|
||||
pub(crate) command: Command,
|
||||
/// Semantic identities resolved from every admitted exact target.
|
||||
pub(crate) selected: BTreeSet<Id>,
|
||||
/// Actual identity and surviving-record differences after the complete batch.
|
||||
pub(crate) removed: BTreeSet<Id>,
|
||||
pub(crate) changed: BTreeSet<Id>,
|
||||
pub(crate) interpretation: Vec<crate::removal_effects::InterpretationChange>,
|
||||
/// Changed or deleted definitions and surviving transitive callers.
|
||||
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(project: &Project, diagram: Id, targets: &[Target]) -> Result<Plan, String> {
|
||||
let saved = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or("The saved view no longer exists.")?;
|
||||
if !saved.projection.is_architecture() && saved.projection.state_machine().is_none() {
|
||||
return Err("Delete from model supports saved Architecture and State views here.".into());
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Err("Select at least one visible model object to delete.".into());
|
||||
}
|
||||
validate_candidate(project).map_err(|error| error.to_string())?;
|
||||
let view = projection::project_diagram(project, saved);
|
||||
let mut selected = BTreeSet::new();
|
||||
// Resolve every original typed identity BEFORE removing any cascade overlap.
|
||||
// A stale/hidden/wrong-kind target cannot become admissible because another
|
||||
// selection member would happen to delete its semantic identity.
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
if targets[..index].contains(target) {
|
||||
return Err("Every selected exact target must be unique.".into());
|
||||
}
|
||||
let id = match target {
|
||||
Target::Placement(id) => saved.nodes.get(id)
|
||||
.filter(|node| view.nodes.iter().any(|visible| visible.id == *id && visible.element == node.element))
|
||||
.map(|node| node.element)
|
||||
.ok_or("A selected placement is no longer visible in this saved view.")?,
|
||||
Target::Port { port, placement } => {
|
||||
let owner = saved.nodes.get(placement)
|
||||
.ok_or("A selected port's exact owner placement no longer exists in this view.")?;
|
||||
if !view.ports.iter().any(|visible| visible.element == *port && visible.owner == *placement)
|
||||
|| !project.elements.get(port).is_some_and(|element| element.kind() == Kind::Port && element.parent == Some(owner.element))
|
||||
{
|
||||
return Err("A selected port is no longer visible on its exact owner placement.".into());
|
||||
}
|
||||
*port
|
||||
}
|
||||
Target::Edge(id) => {
|
||||
if !view.edges.iter().any(|edge| edge.id == *id)
|
||||
|| !(project.relations.contains_key(id) || project.traces.contains_key(id))
|
||||
{
|
||||
return Err("Every selected edge must be a visible stored relation or trace; inferred edges are read-only.".into());
|
||||
}
|
||||
*id
|
||||
}
|
||||
Target::StateRow { placement, relation } => {
|
||||
if !crate::behavior::state_canvas::visible_row_target(project, diagram, &view, *placement, *relation) {
|
||||
return Err("A selected State row is no longer visible on its exact placement.".into());
|
||||
}
|
||||
*relation
|
||||
}
|
||||
_ => return Err("The complete selection must contain only visible placements, ports, stored edges or State rows; grouping and read-only targets cannot be deleted here.".into()),
|
||||
};
|
||||
selected.insert(id);
|
||||
}
|
||||
|
||||
// Prefer cascades covering the most remaining selected subjects. Selecting a
|
||||
// parent together with its child/port/incident relation must not issue Delete
|
||||
// for an identity already removed by that parent. Stable ID order breaks ties.
|
||||
// Core defers conditional exchange-allocation cleanup until batch completion;
|
||||
// admission and the final result are therefore never singleton-validated.
|
||||
let impacts: BTreeMap<_, _> = selected
|
||||
.iter()
|
||||
.map(|&id| (id, references::deletion_impact(project, id)))
|
||||
.collect();
|
||||
let mut pending = selected.clone();
|
||||
let mut commands = Vec::new();
|
||||
while !pending.is_empty() {
|
||||
let id = *pending
|
||||
.iter()
|
||||
.max_by_key(|&&id| {
|
||||
(
|
||||
impacts[&id].intersection(&pending).count(),
|
||||
std::cmp::Reverse(id),
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
commands.push(Command::Delete(id));
|
||||
pending.retain(|remaining| !impacts[&id].contains(remaining));
|
||||
}
|
||||
let command = Command::Batch(commands);
|
||||
let candidate = execute_batch(project, std::slice::from_ref(&command))
|
||||
.map_err(|error| error.to_string())?;
|
||||
let original: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
let retained: BTreeSet<_> = candidate
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
if !retained.is_subset(&original) {
|
||||
return Err("Removal unexpectedly created model identities; no change was applied.".into());
|
||||
}
|
||||
let removed: BTreeSet<_> = original.difference(&retained).copied().collect();
|
||||
if !selected.is_subset(&removed) {
|
||||
return Err("The complete selected set could not be removed.".into());
|
||||
}
|
||||
let mut changed = BTreeSet::new();
|
||||
changed_records(&project.elements, &candidate.elements, &mut changed);
|
||||
changed_records(&project.relations, &candidate.relations, &mut changed);
|
||||
changed_records(&project.diagrams, &candidate.diagrams, &mut changed);
|
||||
changed_records(&project.traces, &candidate.traces, &mut changed);
|
||||
changed_records(
|
||||
&project.artifact_links,
|
||||
&candidate.artifact_links,
|
||||
&mut changed,
|
||||
);
|
||||
for (&id, diagram) in &project.diagrams {
|
||||
if let Some(after) = candidate.diagrams.get(&id) {
|
||||
changed_records(&diagram.nodes, &after.nodes, &mut changed);
|
||||
}
|
||||
}
|
||||
let crate::removal_effects::DerivedEffects {
|
||||
interpretation,
|
||||
affected_uses,
|
||||
} = crate::removal_effects::analyze(project, &candidate, &removed, &changed);
|
||||
Ok(Plan {
|
||||
command,
|
||||
selected,
|
||||
removed,
|
||||
changed,
|
||||
interpretation,
|
||||
affected_uses,
|
||||
})
|
||||
}
|
||||
|
||||
fn changed_records<T: PartialEq>(
|
||||
before: &BTreeMap<Id, T>,
|
||||
after: &BTreeMap<Id, T>,
|
||||
changed: &mut BTreeSet<Id>,
|
||||
) {
|
||||
changed.extend(
|
||||
before
|
||||
.iter()
|
||||
.filter_map(|(&id, record)| after.get(&id).filter(|next| *next != record).map(|_| id)),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conditional_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,302 +0,0 @@
|
||||
//! Independent conditional-cascade checks for overlapping selected endpoint allocations.
|
||||
use super::prepare;
|
||||
use crate::diagram_usage::Target;
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
selected_allocations: [Id; 2],
|
||||
exchange_allocation: Id,
|
||||
functional_exchange: Id,
|
||||
}
|
||||
|
||||
fn relation(project: &mut Project, number: u128, alias: &str, body: RelationBody) -> Id {
|
||||
let id = Id::from_u128(number);
|
||||
assert!(
|
||||
project
|
||||
.relations
|
||||
.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
body,
|
||||
}
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(reverse_ids: bool, invalid_dual_mapping: bool) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let performer = project
|
||||
.add_element(Kind::System, Layer::Sa, "Send performer", None)
|
||||
.unwrap();
|
||||
let receiver = if invalid_dual_mapping {
|
||||
performer
|
||||
} else {
|
||||
project
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Receive performer", None)
|
||||
.unwrap()
|
||||
};
|
||||
let source = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let retained = project.relate(RelationKind::Allocation, "Performs send", performer, source);
|
||||
project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Performs receive",
|
||||
receiver,
|
||||
target,
|
||||
);
|
||||
let functional_exchange = project.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Information",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
let (output, input) = match project.relations[&functional_exchange].body {
|
||||
RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => (source_port, target_port),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let mut component_ports = vec![];
|
||||
for (owner, name) in [
|
||||
(performer, "First component port"),
|
||||
(receiver, "Second component port"),
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&project,
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::InOut,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.elements.insert(id, port);
|
||||
component_ports.push(id);
|
||||
}
|
||||
let [a, b] = [component_ports[0], component_ports[1]];
|
||||
let boundary = relation(
|
||||
&mut project,
|
||||
300,
|
||||
"boundary",
|
||||
RelationBody::BoundaryExchange {
|
||||
source: a,
|
||||
target: b,
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
},
|
||||
);
|
||||
// Either endpoint allocation is required. Their overlapping conditional
|
||||
// cascade must remove the exchange allocation once, after one whole batch.
|
||||
let numbers = if reverse_ids { [200, 100] } else { [100, 200] };
|
||||
let first = relation(
|
||||
&mut project,
|
||||
numbers[0],
|
||||
"first_output",
|
||||
RelationBody::PortAllocation {
|
||||
source: a,
|
||||
target: output,
|
||||
},
|
||||
);
|
||||
let second = relation(
|
||||
&mut project,
|
||||
numbers[1],
|
||||
"second_input",
|
||||
RelationBody::PortAllocation {
|
||||
source: b,
|
||||
target: input,
|
||||
},
|
||||
);
|
||||
if invalid_dual_mapping {
|
||||
// Two simultaneous direct/reverse mappings would need the same actual
|
||||
// performer at both ends, violating the different-boundary-owners rule.
|
||||
relation(
|
||||
&mut project,
|
||||
400,
|
||||
"first_input",
|
||||
RelationBody::PortAllocation {
|
||||
source: a,
|
||||
target: input,
|
||||
},
|
||||
);
|
||||
relation(
|
||||
&mut project,
|
||||
500,
|
||||
"second_output",
|
||||
RelationBody::PortAllocation {
|
||||
source: b,
|
||||
target: output,
|
||||
},
|
||||
);
|
||||
}
|
||||
let exchange_allocation = relation(
|
||||
&mut project,
|
||||
150,
|
||||
"realizes_exchange",
|
||||
RelationBody::ExchangeAllocation {
|
||||
source: boundary,
|
||||
target: functional_exchange,
|
||||
},
|
||||
);
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
for (element, x) in [
|
||||
(performer, 40.),
|
||||
(receiver, 1360.),
|
||||
(source, 480.),
|
||||
(target, 920.),
|
||||
] {
|
||||
project.place(diagram, element, Position { x, y: 200. });
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes = [first, retained]
|
||||
.into_iter()
|
||||
.map(|edge| EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 360., y: 350. }],
|
||||
label: None,
|
||||
})
|
||||
.collect();
|
||||
Fixture {
|
||||
project,
|
||||
diagram,
|
||||
selected_allocations: [first, second],
|
||||
exchange_allocation,
|
||||
functional_exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_conditional_allocation_loss_is_reviewed_from_final_candidate() {
|
||||
for reverse_ids in [false, true] {
|
||||
let f = fixture(reverse_ids, false);
|
||||
validate_candidate(&f.project)
|
||||
.expect("Separate actual performers and required endpoint mappings are valid");
|
||||
for selected in f.selected_allocations {
|
||||
assert!(
|
||||
references::deletion_impact(&f.project, selected).contains(&f.exchange_allocation),
|
||||
"Both selected allocations overlap on the same conditional dependent"
|
||||
);
|
||||
let singleton = execute_batch(&f.project, &[Command::Delete(selected)]).unwrap();
|
||||
assert!(!singleton.relations.contains_key(&f.exchange_allocation));
|
||||
}
|
||||
let targets = f.selected_allocations.map(Target::Edge);
|
||||
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
|
||||
let reversed = prepare(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&[targets[1].clone(), targets[0].clone()],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&plan.command).unwrap(),
|
||||
serde_json::to_value(&reversed.command).unwrap(),
|
||||
"Click order must not change normalization"
|
||||
);
|
||||
let removed = BTreeSet::from([
|
||||
f.selected_allocations[0],
|
||||
f.selected_allocations[1],
|
||||
f.exchange_allocation,
|
||||
]);
|
||||
assert_eq!(plan.selected, BTreeSet::from(f.selected_allocations));
|
||||
assert_eq!(plan.removed, removed);
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
|
||||
// Literal record oracle, independent of deletion_impact / planner output.
|
||||
let mut expected = f.project.clone();
|
||||
for id in removed {
|
||||
expected.relations.remove(&id);
|
||||
}
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.remove(0);
|
||||
let actual = execute_batch(&f.project, &[plan.command]).unwrap();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Only two port allocations, their conditional exchange allocation and the deleted edge's authored route change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_exchange_cascade_and_conditional_support_loss_share_one_batch() {
|
||||
for reverse_ids in [false, true] {
|
||||
let f = fixture(reverse_ids, false);
|
||||
validate_candidate(&f.project)
|
||||
.expect("Separate actual performers and required endpoint mappings are valid");
|
||||
let targets = [
|
||||
Target::Edge(f.selected_allocations[0]),
|
||||
Target::Edge(f.functional_exchange),
|
||||
Target::Edge(f.selected_allocations[1]),
|
||||
];
|
||||
let plan = prepare(&f.project, f.diagram, &targets).unwrap();
|
||||
let removed = BTreeSet::from([
|
||||
f.selected_allocations[0],
|
||||
f.selected_allocations[1],
|
||||
f.functional_exchange,
|
||||
f.exchange_allocation,
|
||||
]);
|
||||
assert_eq!(
|
||||
plan.selected,
|
||||
BTreeSet::from([
|
||||
f.selected_allocations[0],
|
||||
f.selected_allocations[1],
|
||||
f.functional_exchange
|
||||
])
|
||||
);
|
||||
assert_eq!(plan.removed, removed);
|
||||
assert_eq!(plan.changed, BTreeSet::from([f.diagram]));
|
||||
let mut expected = f.project.clone();
|
||||
for id in removed {
|
||||
expected.relations.remove(&id);
|
||||
}
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.remove(0);
|
||||
assert_eq!(
|
||||
execute_batch(&f.project, &[plan.command]).unwrap(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternative_pairing_counterexample_is_not_a_valid_saved_model() {
|
||||
let f = fixture(false, true);
|
||||
assert!(validation::validate(&f.project).iter().any(
|
||||
|issue| issue.code == "boundary_owner" && issue.severity == validation::Severity::Error
|
||||
));
|
||||
let before = f.project.clone();
|
||||
assert!(
|
||||
prepare(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&f.selected_allocations.map(Target::Edge)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,6 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
|
||||
@@ -178,25 +178,10 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
if *modifiers == iced::keyboard::Modifiers::SHIFT {
|
||||
let Some(input_generation) = self.removal_input.idle() else {
|
||||
return Some(Action::capture());
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::SavedRemoval(Box::new(
|
||||
crate::app::saved_removal::Event::Begin {
|
||||
input_generation,
|
||||
context: self.context,
|
||||
snapshot: Box::new(snapshot),
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let action = if modifiers.is_empty() {
|
||||
Event::Hide(snapshot)
|
||||
} else {
|
||||
Event::Refuse(snapshot, "Use plain Delete to hide placements or Shift+Delete to review deletion from the model.".into())
|
||||
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
|
||||
};
|
||||
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
removal_input: crate::removal_input::Lifetime,
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
@@ -71,7 +70,6 @@ impl Fixture {
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
Self {
|
||||
removal_input: Default::default(),
|
||||
project,
|
||||
diagram,
|
||||
a,
|
||||
@@ -107,7 +105,6 @@ impl Fixture {
|
||||
_ => self.diagram,
|
||||
};
|
||||
DiagramCanvas {
|
||||
removal_input: &self.removal_input,
|
||||
selection: Some(selection),
|
||||
project: &self.project,
|
||||
view_index: None,
|
||||
|
||||
@@ -61,7 +61,6 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
@@ -180,7 +179,7 @@ fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(matches!(&messages[0],Message::CanvasSelect(_, id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
@@ -252,7 +251,7 @@ fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
Message::CanvasSelect(_, id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
@@ -301,7 +300,7 @@ fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
matches!(messages.first(),Some(Message::CanvasSelect(_, id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
fn canvas<'a>(
|
||||
project: &'a Project,
|
||||
diagram: Id,
|
||||
zoom: f32,
|
||||
removal_input: &'a crate::removal_input::Lifetime,
|
||||
) -> DiagramCanvas<'a> {
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
removal_input,
|
||||
selection: None,
|
||||
project,
|
||||
view_index: None,
|
||||
@@ -64,8 +58,7 @@ fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
|
||||
..Default::default()
|
||||
};
|
||||
for zoom in [0.0001, 0.35, 1., 3.] {
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let canvas = canvas(&project, diagram, zoom, &removal_input);
|
||||
let canvas = canvas(&project, diagram, zoom);
|
||||
for _ in 0..2 {
|
||||
for port in &view.ports {
|
||||
assert_eq!(
|
||||
@@ -98,8 +91,7 @@ fn coincident_ports_keep_selection_then_use_stable_identity() {
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let mut canvas = canvas(&project, diagram, 0.35, &removal_input);
|
||||
let mut canvas = canvas(&project, diagram, 0.35);
|
||||
let at = local(&canvas, view.ports[0].position);
|
||||
for _ in 0..2 {
|
||||
canvas.selected = Id::nil();
|
||||
@@ -155,8 +147,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
let removal_input = crate::removal_input::Lifetime::default();
|
||||
let canvas = canvas(&project, diagram, 0.35, &removal_input);
|
||||
let canvas = canvas(&project, diagram, 0.35);
|
||||
let view = project_view(&project, diagram);
|
||||
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
|
||||
for port in &view.ports {
|
||||
@@ -174,7 +165,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
|
||||
assert!(matches!(press, Some(Message::CanvasSelect(_, id)) if id == port.element));
|
||||
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
|
||||
let menu = canvas.context_request(&state, at, bounds);
|
||||
assert_eq!(menu.object, port.element);
|
||||
|
||||
@@ -177,7 +177,6 @@ mod tests {
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
@@ -376,7 +375,7 @@ mod tests {
|
||||
messages.extend(message);
|
||||
}
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
|
||||
assert!(matches!(messages.first(), Some(Message::CanvasSelect(_, id)) if *id == port));
|
||||
assert!(
|
||||
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
|
||||
);
|
||||
|
||||
@@ -184,10 +184,6 @@ impl Selection {
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub(crate) fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
|
||||
@@ -322,6 +322,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Show a correspondence between OA and SA",
|
||||
&[
|
||||
"Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.",
|
||||
"Switching back to Architecture or another single-layer projection shows only that view's layer. The other layer's placements, routing and saved visibility remain stored; return to OA ↔ SA traceability to show them again, subject to the view's filters. Selecting a displayed card, port or line and opening its Properties keeps the current view and perspective. Applying ordinary Properties edits keeps that saved architecture view open. Changing a placement's Diagram explicitly moves it and follows its destination. Changes you make to the view's own perspective or identity still take effect. If the app asks you to select again, choose a visible target.",
|
||||
"In a mixed traceability view, use Draw from a visible source's canvas menu or the Model explorer sidebar on the Architecture page to connect from that source's OA or SA layer without leaving the view. Choose a highlighted compatible visible target; Escape cancels. The palette offers tools for the view's perspective. Opening an endpoint or selecting a record in the Model explorer sidebar follows that record's perspective. Contextual relationship forms keep the view while opening; applying the reviewed relationship can navigate to its result.",
|
||||
"Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.",
|
||||
"In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.",
|
||||
"The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.",
|
||||
|
||||
@@ -26,8 +26,6 @@ mod port_edit;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod recovery;
|
||||
mod removal_effects;
|
||||
mod removal_input;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_annotations;
|
||||
|
||||
@@ -19,7 +19,6 @@ pub struct Request {
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub process: Option<Box<crate::behavior::topology::Context>>,
|
||||
pub scenario: Option<Box<crate::behavior::scenario::Context>>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
@@ -35,7 +34,6 @@ impl Request {
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
process: None,
|
||||
scenario: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +55,6 @@ pub struct Opened {
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
diagram: Option<Id>,
|
||||
scenario_selection: Option<crate::diagram_selection::Snapshot>,
|
||||
}
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
@@ -70,24 +67,9 @@ impl Opened {
|
||||
.process
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.topology_context())
|
||||
|| request
|
||||
.scenario
|
||||
.as_deref()
|
||||
.is_some_and(|c| Some(*c) != app.scenario_context())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let scenario_selection = if request.scenario.is_some()
|
||||
|| request.process.is_some()
|
||||
|| (request.canvas.is_some()
|
||||
&& app
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1))
|
||||
{
|
||||
Some(app.multiselect_snapshot()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
@@ -97,7 +79,6 @@ impl Opened {
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
scenario_selection,
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
@@ -118,15 +99,6 @@ impl Opened {
|
||||
.process
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.topology_context())
|
||||
&& self
|
||||
.request
|
||||
.scenario
|
||||
.as_deref()
|
||||
.is_none_or(|c| Some(*c) == app.scenario_context())
|
||||
&& self
|
||||
.scenario_selection
|
||||
.as_ref()
|
||||
.is_none_or(|captured| app.multiselect_snapshot().as_ref() == Some(captured))
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
@@ -137,19 +109,9 @@ impl Opened {
|
||||
|
||||
fn target_matches(app: &App, request: &Request) -> bool {
|
||||
use crate::diagram_usage::Target;
|
||||
if request.scenario.is_some()
|
||||
&& (request.canvas.is_some() || request.process.is_some() || request.placement.is_some())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if let Some(context) = request.scenario.as_deref() {
|
||||
return Some(*context) == app.scenario_context()
|
||||
&& matches!(target, Target::ScenarioObject(id) if *id == request.object)
|
||||
&& app.multiselect_target_visible(target);
|
||||
}
|
||||
if (request.canvas.is_none() && request.process.is_none())
|
||||
|| !app.multiselect_target_visible(target)
|
||||
{
|
||||
@@ -197,17 +159,6 @@ fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| {
|
||||
app.scenario_context()
|
||||
.map(|context| match context.representation {
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner } => {
|
||||
owner
|
||||
}
|
||||
crate::behavior::scenario_layout::Representation::Saved { diagram } => {
|
||||
diagram
|
||||
}
|
||||
})
|
||||
})
|
||||
.or_else(|| app.state_context().map(|context| context.diagram))
|
||||
} else {
|
||||
app.active_diagram
|
||||
@@ -221,56 +172,49 @@ pub fn open(object: Id, anchor: Point) -> Message {
|
||||
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
crate::context_menu::Area::left(
|
||||
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
||||
move |anchor| Message::Menu(Event::Open(actions_request(app, anchor))),
|
||||
move |anchor| {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
Message::Menu(Event::Open(request))
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn actions_request(app: &App, anchor: Point) -> Request {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
request.process = app.topology_context().map(Box::new);
|
||||
request.scenario = app.scenario_context().map(Box::new);
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
crate::diagram_usage::Target::ScenarioObject(id) => {
|
||||
request.object = *id;
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
||||
let request = &opened.request;
|
||||
@@ -317,78 +261,6 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if let Some(context) = request.scenario.as_deref().copied() {
|
||||
use crate::app::multiselect::Choice;
|
||||
use crate::behavior::scenario::{CanvasEvent, selection};
|
||||
let snapshot = opened.scenario_selection.as_ref()?;
|
||||
let selection_message = |intent| {
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(selection::Event {
|
||||
context,
|
||||
snapshot: snapshot.clone(),
|
||||
intent,
|
||||
})))
|
||||
};
|
||||
if let Some(target) = request.selection_target.as_deref() {
|
||||
let selected = snapshot.targets().contains(target);
|
||||
entries.push(item(
|
||||
if selected {
|
||||
"Remove from selection"
|
||||
} else {
|
||||
"Add to selection"
|
||||
},
|
||||
selection_message(selection::Intent::Choose(
|
||||
target.clone(),
|
||||
if selected {
|
||||
Choice::Remove
|
||||
} else {
|
||||
Choice::Add
|
||||
},
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if snapshot.targets().len() > 1 {
|
||||
if selected {
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
selection_message(selection::Intent::Choose(
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
selection_message(selection::Intent::Choose(target.clone(), Choice::Replace)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
// A menu on an unselected subject must not expose singleton model actions
|
||||
// while the canvas still depicts a group. Selection changes are explicit.
|
||||
if snapshot.targets().len() > 1 {
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(Entry::title(
|
||||
"Use Reorder above the canvas to change sequence order",
|
||||
));
|
||||
if let Some(removal) = crate::app::scenario_removal::begin(app) {
|
||||
entries.push(item("Remove selected from Scenario…", removal, true));
|
||||
}
|
||||
entries.push(Entry::title(
|
||||
"Select one occurrence for its individual actions",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (request.canvas.is_some() || request.process.is_some())
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
@@ -442,50 +314,22 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
)),
|
||||
true,
|
||||
));
|
||||
// Navigation belongs to the exact clicked preview, even when it
|
||||
// is outside the current group. Keep it alongside aggregate removal
|
||||
// without selecting that preview or exposing singleton mutations.
|
||||
let qualified_definition = request.process.as_deref().copied().and_then(|context| {
|
||||
let endpoint = match request.selection_target.as_deref() {
|
||||
Some(Target::ProcessOccurrence(endpoint))
|
||||
if !endpoint.references.is_empty() =>
|
||||
{
|
||||
Some(endpoint.clone())
|
||||
}
|
||||
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
|
||||
_ => None,
|
||||
}?;
|
||||
Some((context, endpoint))
|
||||
});
|
||||
if let Some((context, endpoint)) = &qualified_definition {
|
||||
entries.push(Entry::title("Reused contents · read-only in this process"));
|
||||
entries.push(item(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
*context,
|
||||
endpoint.clone(),
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
||||
&& (selected || request.process.is_some() || request.canvas.is_some())
|
||||
&& selected
|
||||
{
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
if selected {
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
@@ -499,13 +343,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
entries.push(Entry::title(
|
||||
"Drag contributions within one column to reorder",
|
||||
));
|
||||
if let Some(removal) = crate::app::process_removal::begin(app) {
|
||||
entries.push(item(
|
||||
"Remove selected contributions / links…",
|
||||
removal,
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Hide unavailable · cards are model contributions",
|
||||
));
|
||||
} else {
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
@@ -513,11 +353,9 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
}
|
||||
if request.process.is_none()
|
||||
&& let Some(removal) = crate::app::saved_removal::begin(app)
|
||||
{
|
||||
entries.push(item("Delete selected from model…", removal, true));
|
||||
}
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
@@ -526,13 +364,34 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if qualified_definition.is_some() {
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
if let Some(context) = request.process.as_deref().copied() {
|
||||
let endpoint = match request.selection_target.as_deref() {
|
||||
Some(Target::ProcessOccurrence(endpoint))
|
||||
if !endpoint.references.is_empty() =>
|
||||
{
|
||||
Some(endpoint.clone())
|
||||
}
|
||||
Some(Target::ProcessPreviewLink { source, .. }) => Some(source.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(endpoint) = endpoint {
|
||||
entries.push(Entry::title("Reused contents · read-only in this process"));
|
||||
entries.push(item(
|
||||
"Open referenced definition",
|
||||
Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::OpenQualified(
|
||||
context, endpoint,
|
||||
),
|
||||
),
|
||||
true,
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,10 +557,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(
|
||||
p, diagram,
|
||||
))
|
||||
.any(|element| element == id)
|
||||
crate::connection_edit::can_begin(p, diagram, *tool, id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
@@ -939,10 +795,6 @@ fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_menu_scenario_tests.rs"]
|
||||
mod scenario_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
app::multiselect::Choice,
|
||||
behavior::scenario::{CanvasEvent, Context, selection},
|
||||
behavior::scenario_layout::Representation,
|
||||
diagram_usage::Target,
|
||||
};
|
||||
|
||||
fn saved(project: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("scenario_menu_view_{}", project.diagrams.len()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
nodes: Default::default(),
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture() -> (App, [Id; 3], Id, [Id; 2]) {
|
||||
let (mut project, [owner, _, _], [first, second, hidden]) =
|
||||
crate::behavior::scenario::direct_tests::navigation_fixture();
|
||||
let mut third = project.elements[&first].clone();
|
||||
third.id = Id::new_v4();
|
||||
third.alias = "third_menu_reference".into();
|
||||
third.name = "Third reference".into();
|
||||
let third_id = third.id;
|
||||
project = execute_batch(&project, &[Command::CreateElement(third)]).unwrap();
|
||||
let views = [
|
||||
saved(&mut project, owner, "A"),
|
||||
saved(&mut project, owner, "B"),
|
||||
];
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(owner));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
(app, [first, second, third_id], hidden, views)
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, id: Id, choice: Choice) {
|
||||
let event = selection::Event {
|
||||
context: app.scenario_context().unwrap(),
|
||||
snapshot: app.multiselect_snapshot().unwrap(),
|
||||
intent: selection::Intent::Choose(Target::ScenarioObject(id), choice),
|
||||
};
|
||||
send(
|
||||
app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Selection(Box::new(event))),
|
||||
);
|
||||
}
|
||||
|
||||
fn request(app: &App, id: Id) -> Request {
|
||||
let mut request = Request::object(id, Point::new(120., 80.));
|
||||
request.scenario = app.scenario_context().map(Box::new);
|
||||
request.selection_target = Some(Box::new(Target::ScenarioObject(id)));
|
||||
request
|
||||
}
|
||||
|
||||
fn action(app: &App, caption: &str) -> Message {
|
||||
popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Expected enabled Scenario menu action {caption}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_requests_require_exact_visible_target_and_complete_exclusive_context() {
|
||||
let (mut app, [first, second, _], hidden, [view, _]) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
let valid = request(&app, first);
|
||||
assert!(Opened::new(&app, valid.clone(), 1).is_some());
|
||||
let current = app.scenario_context().unwrap();
|
||||
for captured in [
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
scenario: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
representation: Representation::Saved { diagram: view },
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
visit: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
let mut stale = valid.clone();
|
||||
stale.scenario = Some(Box::new(captured));
|
||||
assert!(Opened::new(&app, stale, 2).is_none());
|
||||
}
|
||||
let mut wrong = valid.clone();
|
||||
wrong.object = second;
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
assert!(app.project().contains(hidden));
|
||||
assert!(!app.multiselect_target_visible(&Target::ScenarioObject(hidden)));
|
||||
assert!(Opened::new(&app, request(&app, hidden), 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.placement = Some(Id::new_v4());
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.canvas = Some(route_edit::Context {
|
||||
document: current.document,
|
||||
diagram: view,
|
||||
revision: current.revision,
|
||||
epoch: current.epoch,
|
||||
});
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.process = Some(Box::new(crate::behavior::topology::Context {
|
||||
document: current.document,
|
||||
owner: current.scenario,
|
||||
revision: current.revision,
|
||||
epoch: current.epoch,
|
||||
}));
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid.clone();
|
||||
wrong.selection_target = Some(Box::new(Target::Edge(first)));
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
let mut wrong = valid;
|
||||
wrong.scenario = None;
|
||||
assert!(Opened::new(&app, wrong, 2).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_menu_rejects_saved_visit_roundtrip_even_with_equal_generic_snapshot() {
|
||||
let (mut app, [first, second, _], _, [a, b]) = fixture();
|
||||
let switch = |app: &mut App, diagram| {
|
||||
let context = app.scenario_context().unwrap();
|
||||
send(
|
||||
app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Saved { diagram },
|
||||
)),
|
||||
);
|
||||
};
|
||||
switch(&mut app, a);
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
choose(&mut app, second, Choice::Add);
|
||||
let opened = Opened::new(&app, request(&app, first), 1).unwrap();
|
||||
let old_context = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.diagram_selection.take().unwrap();
|
||||
switch(&mut app, b);
|
||||
assert!(!opened.valid(&app));
|
||||
switch(&mut app, a);
|
||||
assert_ne!(app.scenario_context().unwrap().visit, old_context.visit);
|
||||
// Restore the same shared selection and epoch deliberately. Only the full
|
||||
// Scenario visit may distinguish the old request from this returned view.
|
||||
app.canvas_epoch = old_context.epoch;
|
||||
app.diagram_selection = Some(selected);
|
||||
app.selected = second;
|
||||
assert_eq!(app.multiselect_snapshot(), opened.scenario_selection);
|
||||
assert!(!opened.valid(&app));
|
||||
assert!(Opened::new(&app, opened.request.clone(), 2).is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_group_menus_capture_set_and_never_expose_partial_model_actions() {
|
||||
let (mut app, [first, second, third], _, _) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
choose(&mut app, second, Choice::Add);
|
||||
let before = app.document.snapshot();
|
||||
let capture = actions_request(&app, Point::ORIGIN);
|
||||
assert_eq!(capture.object, second);
|
||||
assert_eq!(
|
||||
capture.selection_target.as_deref(),
|
||||
Some(&Target::ScenarioObject(second))
|
||||
);
|
||||
assert_eq!(capture.scenario.as_deref().copied(), app.scenario_context());
|
||||
assert!(capture.canvas.is_none() && capture.process.is_none() && capture.placement.is_none());
|
||||
|
||||
for target in [first, third] {
|
||||
let captured_request = request(&app, target);
|
||||
send(&mut app, Message::Menu(Event::Open(captured_request)));
|
||||
let snapshot = app.multiselect_snapshot().unwrap();
|
||||
let menu = popup(&app).unwrap();
|
||||
assert!(
|
||||
menu.entries
|
||||
.iter()
|
||||
.any(|e| matches!(e, Entry::Title(label) if label == "2 selected"))
|
||||
);
|
||||
let mut enabled = 0;
|
||||
let mut removals = 0;
|
||||
for entry in menu.entries {
|
||||
match entry {
|
||||
Entry::Action {
|
||||
label,
|
||||
message: Some(Message::Menu(Event::Invoke(_, message))),
|
||||
..
|
||||
} => {
|
||||
if let Message::ScenarioRemoval(event) = &*message {
|
||||
let crate::app::scenario_removal::Event::Begin {
|
||||
context,
|
||||
snapshot: captured,
|
||||
} = &**event
|
||||
else {
|
||||
panic!("Group menu must capture a fresh removal review");
|
||||
};
|
||||
assert_eq!(label, "Remove selected from Scenario…");
|
||||
assert_eq!(Some(*context), app.scenario_context());
|
||||
assert_eq!(captured.as_ref(), &snapshot);
|
||||
removals += 1;
|
||||
continue;
|
||||
}
|
||||
let Message::ScenarioCanvas(CanvasEvent::Selection(event)) = *message else {
|
||||
panic!("Scenario group menu exposed a singleton model action");
|
||||
};
|
||||
assert_eq!(event.context, app.scenario_context().unwrap());
|
||||
assert_eq!(event.snapshot, snapshot);
|
||||
let selection::Intent::Choose(actual_target, choice) = event.intent else {
|
||||
panic!("Unexpected group selection intent");
|
||||
};
|
||||
assert_eq!(actual_target, Target::ScenarioObject(target));
|
||||
assert!(matches!(
|
||||
(label.as_str(), choice),
|
||||
("Add to selection", Choice::Add)
|
||||
| ("Remove from selection", Choice::Remove)
|
||||
| ("Inspect this element", Choice::Inspect)
|
||||
| ("Select only this element", Choice::Replace)
|
||||
));
|
||||
enabled += 1;
|
||||
}
|
||||
Entry::Action {
|
||||
label,
|
||||
message: None,
|
||||
..
|
||||
} => {
|
||||
assert!(!label.contains("Hide"));
|
||||
}
|
||||
Entry::Action {
|
||||
message: Some(_), ..
|
||||
} => panic!("Uncaptured menu action"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(enabled, if target == first { 3 } else { 2 });
|
||||
assert_eq!(removals, 1, "One captured whole-selection removal action");
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
assert_eq!(app.selected, second);
|
||||
}
|
||||
let selected_request = request(&app, first);
|
||||
send(&mut app, Message::Menu(Event::Open(selected_request)));
|
||||
let inspect = action(&app, "Inspect this element");
|
||||
send(&mut app, inspect);
|
||||
assert_eq!(app.selected, first);
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[
|
||||
Target::ScenarioObject(first),
|
||||
Target::ScenarioObject(second)
|
||||
]
|
||||
);
|
||||
let remove_request = request(&app, second);
|
||||
send(&mut app, Message::Menu(Event::Open(remove_request)));
|
||||
let remove = action(&app, "Remove from selection");
|
||||
send(&mut app, remove);
|
||||
assert_eq!(
|
||||
app.diagram_selection.as_ref().unwrap().targets(),
|
||||
&[Target::ScenarioObject(first)]
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_menu_snapshot_rejects_selection_aba_and_singleton_actions_stay_available() {
|
||||
let (mut app, [first, second, _], _, _) = fixture();
|
||||
choose(&mut app, first, Choice::Replace);
|
||||
let captured = request(&app, first);
|
||||
send(&mut app, Message::Menu(Event::Open(captured)));
|
||||
let _ = action(&app, "Properties");
|
||||
let _ = action(&app, "Delete from model…");
|
||||
let opened = app.model_menu.take().unwrap();
|
||||
let context = app.scenario_context();
|
||||
// Isolate generation admission from the adapter's additional epoch defense.
|
||||
let selected = app.diagram_selection.as_mut().unwrap();
|
||||
selected.add(Target::ScenarioObject(second), |_| true);
|
||||
selected.remove(&Target::ScenarioObject(second));
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert_eq!(app.selected, first);
|
||||
assert!(!opened.valid(&app));
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
//! Definition-level Process/FunctionalChain effects of a complete removal candidate.
|
||||
//! Stored record differences and unchanged interpretation modes do not establish
|
||||
//! unchanged caller behavior. This bounded analysis does not expand reuse trees.
|
||||
use oa_core::topology_context::TopologyIndex;
|
||||
use oa_core::{Id, Kind, OccurrenceBody, Project, RelationKind, SharedBody};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct DerivedEffects {
|
||||
pub(crate) interpretation: Vec<InterpretationChange>,
|
||||
/// Definitions whose records or authored topology changed, including deleted
|
||||
/// definitions. Values identify surviving direct and transitive callers in
|
||||
/// either project, even if their own stored records or modes did not change.
|
||||
pub(crate) affected_uses: BTreeMap<Id, BTreeSet<Id>>,
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct InterpretationChange {
|
||||
pub(crate) owner: Id,
|
||||
pub(crate) before: Interpretation,
|
||||
pub(crate) after: Interpretation,
|
||||
pub(crate) callers: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Interpretation {
|
||||
/// None means explicit; Some includes the empty legacy order.
|
||||
pub(crate) local_order: Option<Vec<Id>>,
|
||||
/// Definition-level implicit order used within contextual expansion. This
|
||||
/// does not claim complete expanded connectivity or boundary analysis.
|
||||
pub(crate) expanded_order: Option<Vec<Id>>,
|
||||
pub(crate) contextual_analysis: bool,
|
||||
}
|
||||
|
||||
fn exchange(project: &Project, id: Id) -> bool {
|
||||
matches!(
|
||||
project.elements.get(&id).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn interpretations(project: &Project) -> BTreeMap<Id, Interpretation> {
|
||||
let index = TopologyIndex::new(project);
|
||||
// This is exactly TopologyIndex::with_cache_capacity's relation ownership
|
||||
// rule. Its link_definitions is private; do not expand repeated reuse trees
|
||||
// just to answer this finite definition-level question.
|
||||
let linked: BTreeSet<_> = project
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|relation| {
|
||||
relation.contextual_owner().or_else(|| {
|
||||
(relation.kind() == RelationKind::SequenceLink)
|
||||
.then(|| {
|
||||
project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.map(|e| {
|
||||
let steps = e.steps().expect("Sequence definitions have ordered steps");
|
||||
// All-exchange steps exclude every non-exchange explicit marker, so only
|
||||
// local SequenceLinks remain to test (the desktop discriminator).
|
||||
// legacy_order alone loses the explicit-versus-empty distinction.
|
||||
let local_order = (!project.relations.values().any(|relation| {
|
||||
relation.kind() == RelationKind::SequenceLink
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|element| element.parent)
|
||||
== Some(e.id)
|
||||
&& project
|
||||
.elements
|
||||
.get(&relation.target())
|
||||
.and_then(|element| element.parent)
|
||||
== Some(e.id)
|
||||
}) && steps.iter().all(|id| exchange(project, *id)))
|
||||
.then(|| steps.clone());
|
||||
let children = index.contributions(e.id);
|
||||
// Mirrors topology_context/analysis.rs Builder::expand's legacy-order
|
||||
// discriminator: all indexed contributions (including unlisted ones),
|
||||
// and neither SequenceLink nor ContextualLink definitions. Tests retain
|
||||
// literal discriminators where this intentionally differs from desktop.
|
||||
let expanded_order = (!linked.contains(&e.id)
|
||||
&& children.iter().all(|id| exchange(project, *id)))
|
||||
.then_some(children);
|
||||
(
|
||||
e.id,
|
||||
Interpretation {
|
||||
local_order,
|
||||
expanded_order,
|
||||
contextual_analysis: index.has_contextual_links(e.id),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn caller_edges(project: &Project, edges: &mut BTreeMap<Id, BTreeSet<Id>>) {
|
||||
for element in project.elements.values() {
|
||||
if let (
|
||||
Some(parent),
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::SequenceReference {
|
||||
sequence: Some(sequence),
|
||||
},
|
||||
..
|
||||
}),
|
||||
) = (element.parent, element.shared())
|
||||
{
|
||||
edges.entry(*sequence).or_default().insert(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn callers(
|
||||
owner: Id,
|
||||
edges: &BTreeMap<Id, BTreeSet<Id>>,
|
||||
surviving: &BTreeMap<Id, Interpretation>,
|
||||
) -> BTreeSet<Id> {
|
||||
let mut seen = BTreeSet::from([owner]);
|
||||
let mut pending = vec![owner];
|
||||
while let Some(sequence) = pending.pop() {
|
||||
for &caller in edges.get(&sequence).into_iter().flatten() {
|
||||
if seen.insert(caller) {
|
||||
pending.push(caller);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.remove(&owner);
|
||||
seen.retain(|id| surviving.contains_key(id));
|
||||
seen
|
||||
}
|
||||
|
||||
fn definition_owner(project: &Project, id: Id) -> Option<Id> {
|
||||
let owner = if let Some(element) = project.elements.get(&id) {
|
||||
if matches!(element.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
Some(id)
|
||||
} else {
|
||||
element.parent.filter(|_| {
|
||||
matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action { .. }
|
||||
| OccurrenceBody::Exchange { .. }
|
||||
| OccurrenceBody::Control { .. }
|
||||
| OccurrenceBody::SequenceReference { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
project.relations.get(&id).and_then(|relation| {
|
||||
relation.contextual_owner().or_else(|| {
|
||||
(relation.kind() == RelationKind::SequenceLink)
|
||||
.then(|| {
|
||||
project
|
||||
.elements
|
||||
.get(&relation.source())
|
||||
.and_then(|e| e.parent)
|
||||
})
|
||||
.flatten()
|
||||
})
|
||||
})
|
||||
}?;
|
||||
project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.filter(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
|
||||
.map(|_| owner)
|
||||
}
|
||||
|
||||
/// Analyze the actual accepted before/after projects and their complete record
|
||||
/// differences. Both graphs participate: deleting a definition can clear its
|
||||
/// surviving reference records, but must not erase their previous dependency.
|
||||
pub(crate) fn analyze(
|
||||
project: &Project,
|
||||
candidate: &Project,
|
||||
removed: &BTreeSet<Id>,
|
||||
changed: &BTreeSet<Id>,
|
||||
) -> DerivedEffects {
|
||||
let before = interpretations(project);
|
||||
let after = interpretations(candidate);
|
||||
let mut edges = BTreeMap::new();
|
||||
caller_edges(project, &mut edges);
|
||||
caller_edges(candidate, &mut edges);
|
||||
let affected_owners: BTreeSet<_> = removed
|
||||
.iter()
|
||||
.chain(changed)
|
||||
.flat_map(|&id| {
|
||||
[
|
||||
definition_owner(project, id),
|
||||
definition_owner(candidate, id),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.collect();
|
||||
let affected_uses = affected_owners
|
||||
.into_iter()
|
||||
.map(|owner| (owner, callers(owner, &edges, &after)))
|
||||
.collect();
|
||||
let interpretation = after
|
||||
.iter()
|
||||
.filter_map(|(&owner, next)| {
|
||||
let previous = before.get(&owner)?;
|
||||
(previous != next).then(|| InterpretationChange {
|
||||
owner,
|
||||
before: previous.clone(),
|
||||
after: next.clone(),
|
||||
callers: callers(owner, &edges, &after),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
DerivedEffects {
|
||||
interpretation,
|
||||
affected_uses,
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//! Transient pointer ownership shared by the canvas and its App adapter.
|
||||
//!
|
||||
//! Iced queues messages until the entire native event batch has reached widgets.
|
||||
//! A later press must therefore revoke an earlier removal packet synchronously,
|
||||
//! without relying on the ordering of two queued application messages.
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Lifetime(Arc<AtomicU64>);
|
||||
|
||||
impl Lifetime {
|
||||
/// Keep a retained canvas's private gesture ownership visible to App even
|
||||
/// when its update returns early or emits no application message.
|
||||
pub(crate) fn sync(&self, lease: &mut Option<Lease>, busy: bool) {
|
||||
if !busy
|
||||
|| lease
|
||||
.as_ref()
|
||||
.is_some_and(|lease| !Arc::ptr_eq(&self.0, &lease.lifetime.0))
|
||||
{
|
||||
*lease = None;
|
||||
}
|
||||
if busy && lease.is_none() {
|
||||
*lease = self.acquire();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn observe(&self, event: &iced::widget::canvas::Event) {
|
||||
use iced::{keyboard, mouse, widget::canvas, window};
|
||||
let boundary = matches!(
|
||||
event,
|
||||
canvas::Event::Window(window::Event::Unfocused | window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_)
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::CursorLeft
|
||||
)
|
||||
) || matches!(event, canvas::Event::Keyboard(keyboard::Event::KeyPressed { key, .. })
|
||||
if *key != keyboard::Key::Named(keyboard::key::Named::Delete));
|
||||
if boundary {
|
||||
self.revoke_idle();
|
||||
}
|
||||
}
|
||||
|
||||
/// Even generations are idle; odd generations belong to one live gesture.
|
||||
pub(crate) fn idle(&self) -> Option<u64> {
|
||||
let generation = self.0.load(Ordering::SeqCst);
|
||||
generation.is_multiple_of(2).then_some(generation)
|
||||
}
|
||||
|
||||
/// Retire an idle packet at a native input boundary (including the
|
||||
/// viewport's synthetic Unfocused event when it takes camera ownership).
|
||||
pub(crate) fn revoke_idle(&self) {
|
||||
if let Some(generation) = self.idle() {
|
||||
// Keep overflow terminal and unavailable rather than reusing zero.
|
||||
let next = generation.saturating_add(2);
|
||||
let _ = self
|
||||
.0
|
||||
.compare_exchange(generation, next, Ordering::SeqCst, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire(&self) -> Option<Lease> {
|
||||
let generation = self.idle()?;
|
||||
let active = generation.checked_add(1)?;
|
||||
self.0
|
||||
.compare_exchange(generation, active, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.ok()?;
|
||||
Some(Lease {
|
||||
lifetime: self.clone(),
|
||||
active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Lease {
|
||||
lifetime: Lifetime,
|
||||
active: u64,
|
||||
}
|
||||
|
||||
impl Drop for Lease {
|
||||
fn drop(&mut self) {
|
||||
// A terminal odd generation stays unavailable on overflow. No stale
|
||||
// packet can become valid again through counter wraparound.
|
||||
if let Some(next) = self.active.checked_add(1) {
|
||||
let _ = self.lifetime.0.compare_exchange(
|
||||
self.active,
|
||||
next,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn removal_input_lease_revokes_idle_capture_and_drop_never_reuses_it() {
|
||||
let lifetime = Lifetime::default();
|
||||
let captured = lifetime.idle().unwrap();
|
||||
let clone = lifetime.clone();
|
||||
let lease = clone.acquire().unwrap();
|
||||
assert!(lifetime.idle().is_none());
|
||||
assert!(lifetime.acquire().is_none());
|
||||
drop(lease);
|
||||
assert!(lifetime.idle().is_some());
|
||||
assert_ne!(lifetime.idle(), Some(captured));
|
||||
let after_drop = lifetime.idle();
|
||||
lifetime.revoke_idle();
|
||||
assert_ne!(lifetime.idle(), after_drop);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removal_input_saturation_and_replaced_owner_cannot_reuse_a_packet() {
|
||||
let terminal = Lifetime(Arc::new(AtomicU64::new(u64::MAX - 1)));
|
||||
terminal.revoke_idle();
|
||||
assert!(terminal.idle().is_none() && terminal.acquire().is_none());
|
||||
let original = Lifetime::default();
|
||||
let replacement = Lifetime::default();
|
||||
let captured = original.idle();
|
||||
let mut lease = original.acquire();
|
||||
replacement.sync(&mut lease, true);
|
||||
assert_ne!(original.idle(), captured);
|
||||
assert!(replacement.idle().is_none());
|
||||
drop(lease);
|
||||
assert!(replacement.idle().is_some());
|
||||
}
|
||||
}
|
||||
@@ -326,27 +326,6 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
);
|
||||
}
|
||||
if let Some(target) = app.delete_target.as_ref().filter(|_| app.delete_pending) {
|
||||
if target.saved.is_some() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::saved_removal::view::view(app, target),
|
||||
700.,
|
||||
);
|
||||
}
|
||||
if target.process.is_some() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::process_removal::view::view(app, target),
|
||||
700.,
|
||||
);
|
||||
}
|
||||
if target.scenario.is_some() {
|
||||
return modal_width(
|
||||
root,
|
||||
crate::app::scenario_removal::view::view(app, target),
|
||||
650.,
|
||||
);
|
||||
}
|
||||
return modal(
|
||||
root,
|
||||
column![
|
||||
@@ -489,7 +468,6 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
removal_input: &app.saved_removal_input,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
@@ -682,25 +660,17 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)),
|
||||
));
|
||||
}
|
||||
selection_controls = selection_controls.push(
|
||||
button(text("Delete from model…").size(12))
|
||||
.padding([6, 8])
|
||||
.on_press_maybe(
|
||||
crate::app::saved_removal::begin(app)
|
||||
.filter(|_| app.saved_removal_enabled()),
|
||||
),
|
||||
);
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear",
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Edit only {}",
|
||||
short(app.project().named(app.selected), 12)
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
.wrapping(widget::text::Wrapping::None),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user