Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21977fca6 | ||
|
|
6018ae30b9 | ||
|
|
c7065db76e | ||
|
|
6520a89dc0 | ||
|
|
3a7221b80c | ||
|
|
ac69f555af | ||
|
|
603a59c178 | ||
|
|
0341bca846 | ||
|
|
715036e132 | ||
|
|
a9cd364eac |
@@ -1,4 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.lock text eol=lf
|
||||
@@ -12,9 +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
|
||||
|
||||
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
|
||||
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
|
||||
@@ -92,3 +90,7 @@ 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
|
||||
|
||||
# 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!(
|
||||
|
||||
@@ -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,14 +23,14 @@ mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
#[cfg(test)]
|
||||
mod placement_relocation_regressions;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod scenario_details_tests;
|
||||
mod scenario_diagram;
|
||||
mod scenario_multiselect;
|
||||
pub(crate) mod scenario_reorder;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
@@ -217,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,
|
||||
@@ -386,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),
|
||||
@@ -442,7 +444,6 @@ pub enum Message {
|
||||
BehaviorDiagram(Choice),
|
||||
StateDiagram(crate::behavior::state_edit::Event),
|
||||
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
|
||||
ScenarioReorder(Box<scenario_reorder::Event>),
|
||||
TopologyCanvas(crate::behavior::topology::CanvasEvent),
|
||||
PlaceSelected,
|
||||
HideSelected,
|
||||
@@ -529,7 +530,6 @@ 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,
|
||||
@@ -652,7 +652,6 @@ 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,
|
||||
@@ -955,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()
|
||||
@@ -1009,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) =
|
||||
@@ -1029,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
|
||||
@@ -1179,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() {
|
||||
@@ -1225,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);
|
||||
@@ -1356,45 +1441,8 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
// 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()
|
||||
&& 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.token != *token || !menu.valid(self))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.scenario_group_selected()
|
||||
&& matches!(
|
||||
&message,
|
||||
Message::Command(Command::MoveStep(..)) | Message::Delete
|
||||
)
|
||||
{
|
||||
self.status = "Group reordering and removal need a whole-selection operation. Select one occurrence to edit it individually.".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, _)
|
||||
@@ -1408,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, ..)
|
||||
@@ -1563,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.
|
||||
@@ -1660,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> {
|
||||
@@ -2184,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
|
||||
@@ -2224,7 +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::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
@@ -2733,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) => {
|
||||
@@ -2768,11 +2797,6 @@ impl App {
|
||||
if matches!(*action, Message::DiagramSelection(_))
|
||||
|| (menu.request.process.is_some()
|
||||
&& matches!(*action, Message::TopologyCanvas(_)))
|
||||
|| (menu.request.scenario.is_some()
|
||||
&& matches!(
|
||||
*action,
|
||||
Message::ScenarioCanvas(_) | Message::ScenarioReorder(_)
|
||||
))
|
||||
{
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2793,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);
|
||||
}
|
||||
@@ -2808,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;
|
||||
@@ -2968,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);
|
||||
@@ -2987,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))
|
||||
@@ -3415,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,
|
||||
));
|
||||
@@ -4441,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 {
|
||||
@@ -4455,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 {
|
||||
@@ -4484,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(),
|
||||
@@ -4626,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();
|
||||
@@ -4640,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) {
|
||||
@@ -5061,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;
|
||||
};
|
||||
@@ -5078,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) {
|
||||
@@ -5095,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 {
|
||||
@@ -5165,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()
|
||||
@@ -5191,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);
|
||||
|
||||
@@ -237,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,18 +24,9 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
&& app.architecture.is_none()));
|
||||
Element::new(Bridge {
|
||||
content,
|
||||
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),
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -43,8 +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],
|
||||
reorder_active: bool,
|
||||
reorder_apply: Option<Message>,
|
||||
}
|
||||
|
||||
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
@@ -98,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 {
|
||||
@@ -168,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,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,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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)| {
|
||||
@@ -2071,11 +2006,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.group_press = None;
|
||||
}
|
||||
if (!editable || !self.coordinate_input)
|
||||
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
|
||||
&& let Some(cancelled) = state.cancel_spacing()
|
||||
{
|
||||
return Some(cancelled);
|
||||
@@ -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, .. }
|
||||
@@ -2147,9 +2075,6 @@ impl canvas::Program<Message> for Scene {
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.is_group() {
|
||||
return self.selection_action(Intent::Refuse("Removing multiple Scenario objects is being implemented; select one occurrence to remove it".into()));
|
||||
}
|
||||
if self.removal_target(context.scenario, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
@@ -2164,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()));
|
||||
@@ -2181,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;
|
||||
@@ -2202,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(
|
||||
@@ -2381,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 {
|
||||
@@ -2553,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
|
||||
@@ -2579,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
|
||||
@@ -2596,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
|
||||
@@ -2618,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
|
||||
@@ -2674,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
|
||||
@@ -2704,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
|
||||
@@ -2743,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,12 +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_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,
|
||||
@@ -149,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),
|
||||
@@ -193,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, _)
|
||||
@@ -1261,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,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,30 +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,
|
||||
));
|
||||
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(
|
||||
@@ -313,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"
|
||||
@@ -399,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)),
|
||||
@@ -431,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(),
|
||||
@@ -749,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)
|
||||
@@ -790,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.",
|
||||
@@ -821,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));
|
||||
@@ -978,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, .. },
|
||||
..
|
||||
@@ -1004,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()) {
|
||||
@@ -1016,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)))
|
||||
)
|
||||
]
|
||||
@@ -1047,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()
|
||||
|
||||
@@ -187,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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -993,7 +993,10 @@ impl canvas::Program<Message> for 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
|
||||
@@ -1010,7 +1013,7 @@ impl canvas::Program<Message> for 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
|
||||
@@ -1026,7 +1029,7 @@ impl canvas::Program<Message> for 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));
|
||||
@@ -2441,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,
|
||||
@@ -3054,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(),
|
||||
@@ -3104,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(
|
||||
|
||||
@@ -179,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)
|
||||
);
|
||||
@@ -251,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:?}");
|
||||
@@ -300,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!(
|
||||
|
||||
@@ -165,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);
|
||||
|
||||
@@ -375,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.",
|
||||
|
||||
@@ -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,18 +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() {
|
||||
Some(app.multiselect_snapshot()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
@@ -91,7 +79,6 @@ impl Opened {
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
scenario_selection,
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
@@ -112,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
|
||||
@@ -131,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)
|
||||
{
|
||||
@@ -191,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
|
||||
@@ -215,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;
|
||||
@@ -311,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",
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Remove selected occurrences (not yet available)",
|
||||
));
|
||||
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()
|
||||
{
|
||||
@@ -679,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)
|
||||
}),
|
||||
));
|
||||
}
|
||||
@@ -920,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,303 +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;
|
||||
for entry in menu.entries {
|
||||
match entry {
|
||||
Entry::Action {
|
||||
label,
|
||||
message: Some(Message::Menu(Event::Invoke(_, message))),
|
||||
..
|
||||
} => {
|
||||
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!(
|
||||
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));
|
||||
}
|
||||
@@ -372,3 +372,150 @@ async fn source6_http_explicit_communication_ports_preview_apply_and_restart_pre
|
||||
assert_pair(&reopened);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_traceability_projection_roundtrip_preserves_dormant_http_state() {
|
||||
let f = fixture();
|
||||
let mut model = compile_bundle(&f.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project;
|
||||
let view = model.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.layer = Layer::Sa;
|
||||
view.projection = ProjectionKind::Traceability;
|
||||
// The OA placements and category are intentionally opposite the view's layer.
|
||||
view.view_options.hidden_edges = vec![f.mean];
|
||||
for (index, element) in f.sa.into_iter().enumerate() {
|
||||
model.place(
|
||||
f.diagram,
|
||||
element,
|
||||
Position {
|
||||
x: index as f32 * 400.,
|
||||
y: 300.,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut bundle = f.bundle.clone();
|
||||
bundle.files.insert(
|
||||
bundle.entry.clone(),
|
||||
format!(
|
||||
"// Authored source-6 engineering rationale\n{}",
|
||||
oa_core::language::format(&model).replacen(
|
||||
"{\n",
|
||||
"{\n include \"notes.arcadia\";\n",
|
||||
1
|
||||
)
|
||||
),
|
||||
);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let original = import(&server, &bundle).await;
|
||||
assert_pair(&original);
|
||||
let resource = original.resource_id;
|
||||
let mixed = oa_core::projection::project_view(&original.model, f.diagram);
|
||||
assert!(
|
||||
f.oa.iter()
|
||||
.chain(&f.sa)
|
||||
.all(|id| mixed.nodes.iter().any(|n| n.element == *id))
|
||||
);
|
||||
|
||||
let mut ordinary = original.model.diagrams[&f.diagram].clone();
|
||||
ordinary.projection = ProjectionKind::Allocation;
|
||||
let response = transact(
|
||||
&server,
|
||||
&original,
|
||||
vec![Command::EditDiagram(f.diagram, ordinary.clone())],
|
||||
)
|
||||
.await;
|
||||
let status = response.status();
|
||||
let etag = response
|
||||
.headers()
|
||||
.get("etag")
|
||||
.map(|v| v.to_str().unwrap().to_owned());
|
||||
let result: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(result["changed"], true);
|
||||
assert_eq!(result["dry_run"], false);
|
||||
assert_eq!(result["revision"], original.revision + 1);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(etag.as_deref(), Some(changed.etag().as_str()));
|
||||
let mut expected = original.model.clone();
|
||||
expected.diagrams.insert(f.diagram, ordinary.clone());
|
||||
assert_eq!(
|
||||
changed.model, expected,
|
||||
"only the projection changes; dormant state is retained"
|
||||
);
|
||||
assert_pair(&changed);
|
||||
assert_eq!(
|
||||
changed.source_project.files["notes.arcadia"],
|
||||
bundle.files["notes.arcadia"]
|
||||
);
|
||||
assert!(
|
||||
changed.source_project.files[&bundle.entry]
|
||||
.starts_with("// Authored source-6 engineering rationale\n")
|
||||
);
|
||||
let projected = oa_core::projection::project_view(&changed.model, f.diagram);
|
||||
assert!(projected.nodes.iter().all(|n| !f.oa.contains(&n.element)));
|
||||
assert!(
|
||||
f.sa.iter()
|
||||
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
|
||||
);
|
||||
|
||||
// A dormant option still must name an existing category. Rejection must not
|
||||
// advance the ETag or rewrite either side of the persisted model/source pair.
|
||||
let disk = directory.path().join(format!("{resource}.json"));
|
||||
let accepted_bytes = std::fs::read(&disk).unwrap();
|
||||
ordinary.view_options.participant_categories = CategorySelection::Any(vec![Id::new_v4()]);
|
||||
let rejected = transact(
|
||||
&server,
|
||||
&changed,
|
||||
vec![Command::EditDiagram(f.diagram, ordinary)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let retained = server.get(resource).await;
|
||||
assert_eq!(retained.etag(), changed.etag());
|
||||
assert_eq!(
|
||||
serde_json::to_value(&retained).unwrap(),
|
||||
serde_json::to_value(&changed).unwrap()
|
||||
);
|
||||
assert_eq!(std::fs::read(&disk).unwrap(), accepted_bytes);
|
||||
server.stop().await;
|
||||
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(
|
||||
serde_json::to_value(&reopened).unwrap(),
|
||||
serde_json::to_value(&changed).unwrap()
|
||||
);
|
||||
assert_pair(&reopened);
|
||||
let response = transact(
|
||||
&server,
|
||||
&reopened,
|
||||
vec![Command::EditDiagram(
|
||||
f.diagram,
|
||||
original.model.diagrams[&f.diagram].clone(),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
let status = response.status();
|
||||
let result: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(result["changed"], true);
|
||||
assert_eq!(result["revision"], changed.revision + 1);
|
||||
let restored = server.get(resource).await;
|
||||
assert_eq!(restored.model, original.model);
|
||||
assert_pair(&restored);
|
||||
assert_eq!(
|
||||
restored.source_project.files["notes.arcadia"],
|
||||
bundle.files["notes.arcadia"]
|
||||
);
|
||||
let projected = oa_core::projection::project_view(&restored.model, f.diagram);
|
||||
assert!(
|
||||
f.oa.iter()
|
||||
.chain(&f.sa)
|
||||
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
|
||||
);
|
||||
assert!(!projected.edges.iter().any(|edge| edge.id == f.mean));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ schemas["ViewOptions"] = obj({
|
||||
"coverage": {**ref("Coverage"), "default": "All"},
|
||||
"work_contexts": {**ref("ContextSelection"), "default": "All"},
|
||||
"exchange_labels": {**ref("ExchangeLabels"), "default": "Name"}}, [])
|
||||
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. Traceability permits both-layer hidden/category/context references; ordinary projections require the diagram layer. Filtering preserves unfiltered automatic port positions."
|
||||
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. String projections preserve both-layer hidden/category/context references and latent placements; only Traceability renders both layers. Typed StateMachine/Scenario views retain layer-scoped admission. Filtering preserves unfiltered automatic port positions."
|
||||
schemas["Diagram"]["properties"]["view_options"] = {**ref("ViewOptions"), "default": {}}
|
||||
|
||||
def upsert_variant(variants, name, value):
|
||||
|
||||
@@ -570,8 +570,15 @@ retain their identities and contracts; migration does not silently replace them.
|
||||
``coverage`` and ``work_contexts``, and ``Name`` for ``exchange_labels``.
|
||||
``{"Only":[]}`` hides all relation and trace lines; ``{"Any":[]}`` preserves
|
||||
an explicit empty category/context selection. Category members union within
|
||||
each family, and independent filters intersect. ``Traceability`` permits
|
||||
references from both layers; ordinary projections require the diagram's layer.
|
||||
each family, and independent filters intersect. String projections permit saved
|
||||
references and placements from both layers. Only ``Traceability`` renders both;
|
||||
other string projections filter to the diagram's declared layer. ``EditDiagram``
|
||||
may change between these projections without deleting latent placements, routes
|
||||
or visibility choices. Returning to Traceability restores their applicability.
|
||||
Typed StateMachine/Scenario views retain their strict membership and layer checks.
|
||||
Missing objects, wrong reference kinds/families and duplicate selections remain
|
||||
invalid. Frozen versions 1–6 keep their prior layer admission before migration;
|
||||
older version-7 implementations may reject these newly accepted ordinary views.
|
||||
These presentation choices preserve semantic records, routes, placements and
|
||||
the automatic port positions derived from unfiltered eligible exchanges.
|
||||
|
||||
|
||||
@@ -409,8 +409,12 @@ filters rather than widening them to All.
|
||||
|
||||
Traceability views retain their established two-layer projection: default options
|
||||
preserve eligible OA and SA lines. Their hidden-edge, category and context
|
||||
references may therefore select either layer. All ordinary projections require
|
||||
these references in the declared diagram layer. A filter never hides the other
|
||||
references may therefore select either layer. Ordinary string projections retain
|
||||
these references from either layer as saved presentation choices; they still
|
||||
render only their declared layer. Switching projections MUST preserve those
|
||||
choices, including explicit empty selections. Existing-object, reference-kind,
|
||||
family and uniqueness constraints still apply. Typed StateMachine and Scenario
|
||||
views retain layer-scoped reference admission. A filter never hides the other
|
||||
layer merely because the Traceability diagram has an OA or SA layer label.
|
||||
|
||||
Versions 1–5 reject the new relation, category constructor, diagram field (even
|
||||
@@ -1073,6 +1077,20 @@ capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. These string projections hide
|
||||
inapplicable placements without deleting them or changing semantic records.
|
||||
This includes opposite-layer placements retained after leaving Traceability.
|
||||
Changing between string projections changes only the projection value; placement
|
||||
identities, positions, sizes, collapsed state, port anchors, routes and visibility
|
||||
choices remain authored. Returning to Traceability restores their applicability.
|
||||
Ordinary views MUST NOT render opposite-layer nodes, parents, ports or lines.
|
||||
This current-version admission correction does not broaden frozen source/JSON
|
||||
versions 1–6: their layer-scoped placement and visibility checks remain required
|
||||
before migration. Earlier version-7 implementations can reject ordinary views
|
||||
containing these retained opposite-layer records.
|
||||
|
||||
The explicit RelocateNode operation moves a placement to a different diagram
|
||||
only when its element belongs to the destination layer or the destination is
|
||||
Traceability. Retaining an existing hidden placement through a projection change
|
||||
does not authorize a relocation that immediately hides it in another layer.
|
||||
|
||||
A StateMachine projection MUST reference an existing StateMachine in the
|
||||
diagram's layer. Its placements MUST refer to that machine, its Regions, or
|
||||
|
||||
@@ -12490,7 +12490,7 @@
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": false,
|
||||
"description": "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. Traceability permits both-layer hidden/category/context references; ordinary projections require the diagram layer. Filtering preserves unfiltered automatic port positions."
|
||||
"description": "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. String projections preserve both-layer hidden/category/context references and latent placements; only Traceability renders both layers. Typed StateMachine/Scenario views retain layer-scoped admission. Filtering preserves unfiltered automatic port positions."
|
||||
},
|
||||
"CommunicationPortChoice": {
|
||||
"oneOf": [
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Architectural and product decisions
|
||||
|
||||
## Canvas inspection, contextual drawing and explicit relocation — 2026-10-02
|
||||
|
||||
An ordinary Properties Apply retains the current Architecture diagram, layer and
|
||||
projection. An accepted edit to the active Diagram itself uses its resulting
|
||||
identity, layer and projection. A queued canvas target is revalidated against that retained surface
|
||||
after Apply; an obsolete target is refused with feedback instead of navigating
|
||||
or selecting hidden content. Explicit Select/Open still follows the requested
|
||||
model target. An accepted change to a placement's actual owning diagram is also
|
||||
explicit navigation: it follows the committed destination and resulting UUID.
|
||||
Changing only geometry, alias or UUID in the same owning diagram retains the
|
||||
inspection surface. Invalid or missing ownership never grants this exception.
|
||||
|
||||
Contextual Draw uses the invoked menu target as its semantic source and the current
|
||||
diagram as its editing surface. Shared admission checks the diagram projection,
|
||||
visibility, family and semantic endpoint roles before arming. Restart clears the
|
||||
pending source while retaining an admitted tool. Explicit model navigation and
|
||||
post-review creation navigation retain their separate behavior. This avoids
|
||||
silently conflating an opposite-layer inspection with a request to change views.
|
||||
|
||||
The source repairs at c706 and 6018, the failed c706 workspace attempt, the
|
||||
full-model/source invariants and corrected Windows acceptance are attributed in
|
||||
[the navigation checkpoint](navigation-validation-20261002.md). The simpler
|
||||
blanket surface restoration was rejected when the unchanged relocation test
|
||||
showed that it hid successful explicit moves. This is bounded Architecture
|
||||
acceptance; diagram label readability and the complete OA/SA goal remain open.
|
||||
|
||||
## Private source composition decision — 2026-09-23
|
||||
|
||||
Adopt the opaque validated original-span rename batch as the next private
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
# Navigation contribution checkpoint — 2026-10-02 UTC
|
||||
|
||||
Verified production source **6018ae30b9296b9f6b37d7fc038175080d15fafb** preserves
|
||||
ordinary opposite-layer Properties inspection and contextual drawing while
|
||||
following accepted explicit placement relocation. Windows full workspace tests
|
||||
pass **2,236 / 0 failures / 8 existing ignores**; formatting, strict workspace
|
||||
all-targets Clippy and matching desktop/server/CLI builds pass. Native creation,
|
||||
projection/history, corrected relocation and saved-source reopen evidence retain
|
||||
their actual c706/6018 attribution and independent reviews.
|
||||
[Source, commands, portable evidence and limits](navigation-validation-20261002.md)
|
||||
are authoritative. The earlier c706 workspace failure remains recorded.
|
||||
|
||||
This is a contribution checkpoint, not the complete OA/SA application or an
|
||||
integrated Help/Save release. Label ellipsis/readability and broader usability
|
||||
remain open. **Linux/WSL is still excluded by the user's explicit pause.**
|
||||
[Issue48](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/48)
|
||||
coordinates the subsequent verified union with delivered Save/Help; ownership,
|
||||
live status and release decisions belong there. Existing released versions are
|
||||
unchanged. Earlier integrated and contribution checkpoints follow with their
|
||||
original source and acceptance boundaries.
|
||||
|
||||
# Integrated prerelease source checkpoint — 2026-09-24 UTC
|
||||
|
||||
This branch assembles peer contributions for the Windows prerelease in issue37.
|
||||
@@ -25,7 +46,13 @@ the current code includes that one-line correction. The original compile failed
|
||||
before running tests. The first combined compile at `897d9f04` subsequently
|
||||
found a missing `text_editor` import in the Allocation Text Apply regression;
|
||||
the current source includes that one-line correction too. Both failures remain
|
||||
recorded in the source handoff. Corrected combined execution remains pending.
|
||||
recorded in the source handoff. The subsequent full test run at `8ddfadf4`
|
||||
failed the mounted State picker fixture: added selection controls invalidated
|
||||
its canvas-relative clicks. Source successor `a9cd364eacbbffa0693bab05b5a2b65f9d105535`
|
||||
measures an identified fixed-height picker slot and checks Escape capture,
|
||||
retaining the exact preservation/reopen/Place/Undo assertions. The source
|
||||
handoff links the preserved failed and interrupted attempts. Corrected combined
|
||||
Windows execution and package acceptance remain pending.
|
||||
|
||||
Live build, final-source and publication results belong in
|
||||
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Connection tool icons
|
||||
|
||||
Branch candidate for [#41](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/41), based on published Windows preview source `715036e132677dd2bc2fb37ed316d93dc9987aec`. This is not integrated or native acceptance.
|
||||
|
||||
The reported collapsed palette repeats one arrow for three different tools. Extend the existing Rust/Iced canvas glyphs at their actual 16×16 size: work endpoints plus an arrow for Interaction/Functional exchange, neutral participant endpoints for Communication means, facing component ports for Boundary exchange, and work entering a performer for Allocation. The first pair never coexist in one perspective. Unoriented generic tools must not imply a fixed direction.
|
||||
|
||||
Letter badges were rejected as language-dependent and cramped. Different arrow/dash styles risk confusing tool identity with relation direction. Native endpoint/container shapes fit the existing icon system without font or asset dependencies. Preserve button dimensions, explicit labels/tooltips, ordering, messages, availability and selection rules.
|
||||
|
||||
Independent source critique found no blocker and confirmed the tool semantics and Iced API use. Actual-size legibility remains unverified, particularly the exchange arrow, port details and possible download/import interpretation of Allocation. Verify enabled, disabled, hovered and selected palettes in both perspectives, expansion/collapse, tool activation, representative relation creation and cancellation. Existing keyboard/accessibility behavior must be checked, not inferred from hover labels. Reuse semantic connection tests; geometry hashes alone are not an identification or usability evaluator.
|
||||
|
||||
Linux remains excluded by the existing user pause. Live ownership and integration status belong to the linked issue.
|
||||
@@ -0,0 +1,714 @@
|
||||
"""Navigation evidence transport. Integrity is not product acceptance.
|
||||
|
||||
Commands: freeze TEMPLATE SOURCE_LOCAL NEW_FROZEN_CONFIG;
|
||||
build FROZEN_CONFIG SOURCE_LOCAL FRESH_PACKET; verify PACKET;
|
||||
selftest FRESH_DIRECTORY. No extraction, subprocesses, or native execution.
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
import datetime
|
||||
import copy
|
||||
|
||||
MAX_FILE = 128 * 1024 * 1024
|
||||
MAX_TOTAL = 1024 * 1024 * 1024
|
||||
EXCLUDED = {"bin", "profile", "target", ".git", "navigation-oracle-runtime"}
|
||||
BLOCKED_SUFFIXES = {'.exe', '.dll', '.pdb', '.lib', '.obj', '.pfx', '.pem', '.key'}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pairs(items):
|
||||
out = {}
|
||||
for key, value in items:
|
||||
need(key not in out, "duplicate JSON key: " + key)
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def decode(data):
|
||||
def reject_constant(value):
|
||||
raise ValueError('nonfinite JSON constant: ' + value)
|
||||
return json.loads(data, object_pairs_hook=pairs, parse_constant=reject_constant)
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def safe(name):
|
||||
need(isinstance(name, str) and name and "\\" not in name and ":" not in name,
|
||||
"unsafe path")
|
||||
p = PurePosixPath(name)
|
||||
need(bool(p.parts) and not p.is_absolute() and str(p) == name, "noncanonical path: " + name)
|
||||
need(all(x not in (".", "..") and not x.endswith((".", " ")) for x in p.parts),
|
||||
"unsafe component: " + name)
|
||||
need(not any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", x)
|
||||
for x in p.parts), "reserved component")
|
||||
need(not any(ord(c) < 32 or c in '<>"|?*' for c in name), "nonportable path")
|
||||
return name
|
||||
|
||||
|
||||
def permitted(name):
|
||||
safe(name)
|
||||
parts = PurePosixPath(name).parts
|
||||
need(not any(x.casefold() in EXCLUDED | {'.ssh', '.codex', '.config', 'credentials', 'auth'}
|
||||
or x.casefold().startswith('.env') for x in parts), 'excluded/private input')
|
||||
need(PurePosixPath(name).suffix.casefold() not in BLOCKED_SUFFIXES
|
||||
and PurePosixPath(name).name.casefold() != 'push-navigation.py', 'binary/private input')
|
||||
|
||||
|
||||
def hash_string(value):
|
||||
need(isinstance(value, str) and re.fullmatch('[0-9a-f]{64}', value), 'invalid hash')
|
||||
return value
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
need(isinstance(value, str) and value, 'missing timestamp')
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
need(parsed.tzinfo is not None, 'timestamp needs timezone')
|
||||
return parsed
|
||||
|
||||
|
||||
def inventory(value):
|
||||
need(isinstance(value, dict) and value, 'missing identity inventory')
|
||||
folded = set()
|
||||
for name, sha in value.items():
|
||||
safe(name)
|
||||
need(name.casefold() not in folded, 'identity inventory case collision')
|
||||
folded.add(name.casefold())
|
||||
hash_string(sha)
|
||||
|
||||
|
||||
def validate_config(config, frozen=False):
|
||||
need(config.get('schema') == 'navigation-evidence-config-v2', 'config schema')
|
||||
need(config.get('exclusions') == sorted(EXCLUDED), 'exclusion policy mismatch')
|
||||
includes = config['includes']
|
||||
need(isinstance(includes, list) and includes, 'empty includes')
|
||||
names = []
|
||||
for item in includes:
|
||||
name = safe(item['path'])
|
||||
permitted(name)
|
||||
need(item['kind'] in ('file', 'tree') and isinstance(item['category'], str)
|
||||
and item['category'], 'invalid include')
|
||||
folded = name.casefold()
|
||||
need(not any(folded == old or folded.startswith(old + '/') or old.startswith(folded + '/')
|
||||
for old in names), 'overlapping include roots')
|
||||
names.append(folded)
|
||||
for key in ('gates', 'native_cases', 'oracle_runs'):
|
||||
values = [x['name'] if isinstance(x, dict) else x for x in config[key]]
|
||||
need(len(values) == len(set(values)), 'duplicate case')
|
||||
for name in values:
|
||||
need(re.fullmatch('[a-z0-9-]+', name), 'invalid case name')
|
||||
if frozen:
|
||||
need(config.get('frozen_entries'), 'freeze and review exact file inventory first')
|
||||
|
||||
|
||||
def coverage(config, entries):
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
name = entry['path']
|
||||
permitted(name)
|
||||
need(name.casefold() not in seen, 'duplicate logical path')
|
||||
seen.add(name.casefold())
|
||||
owners = [i for i in config['includes'] if name == i['path'] or
|
||||
(i['kind'] == 'tree' and name.startswith(i['path'] + '/'))]
|
||||
need(len(owners) == 1 and entry['category'] == owners[0]['category'], 'outside whitelist/category')
|
||||
for item in config['includes']:
|
||||
need(any(e['path'] == item['path'] or (item['kind'] == 'tree' and
|
||||
e['path'].startswith(item['path'] + '/')) for e in entries), 'missing required include')
|
||||
for name in config['documentation']:
|
||||
need(safe(name).casefold() in seen, 'missing documentation')
|
||||
|
||||
|
||||
def regular(path):
|
||||
# Also rejects Windows junctions, including ancestors outside the selected root.
|
||||
for item in [path, *path.parents]:
|
||||
s = item.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse: " + str(item))
|
||||
need(path.is_file(), "not a regular file: " + str(path))
|
||||
need(path.stat().st_size <= MAX_FILE, "file size cap")
|
||||
|
||||
|
||||
def read(path):
|
||||
regular(path)
|
||||
data = path.read_bytes()
|
||||
need(len(data) <= MAX_FILE, "file grew over cap")
|
||||
return data
|
||||
|
||||
|
||||
def walk_checked(base, exclude=False):
|
||||
# Check before descending, including junctions; os.walk never gets an
|
||||
# unchecked reparse directory to recurse into.
|
||||
for item in [base, *base.parents]:
|
||||
s = item.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse ancestor")
|
||||
for current, dirs, files in os.walk(base, followlinks=False):
|
||||
if exclude:
|
||||
dirs[:] = [x for x in dirs if x.casefold() not in EXCLUDED]
|
||||
for name in dirs + files:
|
||||
path = Path(current) / name
|
||||
s = path.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse tree")
|
||||
for name in files:
|
||||
yield Path(current) / name
|
||||
|
||||
|
||||
def selected(source, config):
|
||||
found = {}
|
||||
for item in config["includes"]:
|
||||
name = safe(item["path"])
|
||||
base = source / name
|
||||
need(base.exists(), "required input missing: " + name)
|
||||
candidates = [base] if item["kind"] == "file" else sorted(walk_checked(base, exclude=True))
|
||||
need(item["kind"] in ("file", "tree"), "unknown include kind")
|
||||
for file in candidates:
|
||||
relative = file.relative_to(source).as_posix()
|
||||
if any(x.casefold() in EXCLUDED for x in PurePosixPath(relative).parts):
|
||||
continue
|
||||
# Check directories too, before skipping them; never traverse a junction.
|
||||
s = file.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse input")
|
||||
if file.is_dir():
|
||||
continue
|
||||
safe(relative)
|
||||
need(relative.casefold() not in found, "overlapping/case-colliding include")
|
||||
regular(file)
|
||||
found[relative.casefold()] = (relative, item["category"], file)
|
||||
return sorted(found.values())
|
||||
|
||||
|
||||
def check_zip(data, source_inputs):
|
||||
need(isinstance(source_inputs, dict) and source_inputs, "missing source inventory")
|
||||
expected = {}
|
||||
for name, sha in source_inputs.items():
|
||||
safe(name)
|
||||
need(name.casefold() not in expected and re.fullmatch("[0-9a-f]{64}", sha),
|
||||
"invalid source inventory")
|
||||
expected[name.casefold()] = (name, sha)
|
||||
seen = set()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
members = archive.infolist()
|
||||
need(len(members) <= 10000, "ZIP member cap")
|
||||
need(sum(x.file_size for x in members) <= 512 * 1024 * 1024, "ZIP total cap")
|
||||
for member in members:
|
||||
name = safe(member.filename)
|
||||
need(not member.is_dir() and not member.flag_bits & 1, "ZIP directory/encryption")
|
||||
mode = member.external_attr >> 16
|
||||
need(stat.S_IFMT(mode) in (0, stat.S_IFREG), "ZIP nonregular member")
|
||||
need(member.file_size <= 64 * 1024 * 1024, "ZIP member cap")
|
||||
key = name.casefold()
|
||||
need(key not in seen and key in expected and expected[key][0] == name,
|
||||
"ZIP path inventory mismatch")
|
||||
seen.add(key)
|
||||
content = archive.read(member) # zipfile checks CRC; never extracts.
|
||||
need(len(content) == member.file_size and digest(content) == expected[key][1],
|
||||
"ZIP content mismatch")
|
||||
need(seen == set(expected), "ZIP missing inputs")
|
||||
|
||||
|
||||
def streams(record, prefix, get, suffix='.'):
|
||||
for stream in ('stdout', 'stderr'):
|
||||
size = record[stream + '_bytes']
|
||||
need(type(size) is int and size >= 0, 'invalid stream size')
|
||||
path = prefix + suffix + stream if suffix == '.' else prefix + stream + '.log'
|
||||
need(len(get(path)) == size, 'stream size mismatch: ' + path)
|
||||
|
||||
|
||||
def step_sequence(result, planned, get, prefix, stop_on_failure, require_finished=True):
|
||||
steps = result.get('steps')
|
||||
need(isinstance(steps, list) and 0 < len(steps) <= len(planned), 'invalid step count')
|
||||
need(len({s['label'] for s in planned}) == len(planned), 'duplicate plan label')
|
||||
for actual, expected in zip(steps, planned):
|
||||
need(actual['label'] == expected['label'] and actual['command'] == expected['command']
|
||||
and actual.get('expectation', 'exit0') == expected.get('expectation', 'exit0'),
|
||||
'step differs from plan')
|
||||
need(type(actual.get('exit_code')) is int, 'missing step exit')
|
||||
start = actual.get('started', actual.get('start'))
|
||||
need(timestamp(actual['finished']) >= timestamp(start), 'step timestamp order')
|
||||
streams(actual, prefix + safe(actual['label']), get)
|
||||
failed = [i for i,s in enumerate(steps) if s['exit_code'] != 0]
|
||||
if stop_on_failure and failed:
|
||||
need(failed == [len(steps)-1], 'not first-failure stopped prefix')
|
||||
return 'stopped-at-first-failure'
|
||||
need(len(steps) == len(planned), 'gate still open: incomplete successful prefix')
|
||||
if require_finished:
|
||||
need(timestamp(result.get('finished')) >= timestamp(steps[-1]['finished']), 'missing final closure')
|
||||
return 'completed-with-failures' if failed else 'completed-successfully'
|
||||
|
||||
|
||||
def same_fields(before, after, keys):
|
||||
for key in keys:
|
||||
need(key in before and key in after and before[key] is not None and
|
||||
before[key] == after[key], 'identity mismatch/missing: ' + key)
|
||||
|
||||
|
||||
def receipts(config, get, paths):
|
||||
matrix = []
|
||||
for gate in config["gates"]:
|
||||
prefix = "navigation-gates/" + gate["name"] + "/"
|
||||
before = decode(get(prefix + "initial.json"))
|
||||
raw_result = get(prefix + 'result.json')
|
||||
result = decode(raw_result)
|
||||
same_fields(before, result, ('source', 'source_inputs', 'started', 'environment'))
|
||||
need(isinstance(result['source'], str) and re.fullmatch('[0-9a-f]{40}', result['source']), 'source commit')
|
||||
inventory(result['source_inputs'])
|
||||
need(result.get("inputs_unchanged") is True, "gate source conservation missing")
|
||||
if gate['name'] == 'attempt01':
|
||||
need(digest(raw_result) == 'de7c4a7361a4c4991f783c16656af2ac38c49edaaf4d473f26462f3580264486',
|
||||
'historical attempt01 changed')
|
||||
planned = [{'label':a, 'command':b} for a,b in (
|
||||
('fmt','cargo fmt --all -- --check'),
|
||||
('workspace-tests','cargo test --workspace --locked -- --test-threads=1'),
|
||||
('clippy','cargo clippy --workspace --locked --all-targets -- -D warnings'),
|
||||
('native-builds','cargo build --workspace --locked --bins'))]
|
||||
outcome = step_sequence(result, planned, get, prefix, False, False)
|
||||
need(outcome == 'completed-successfully' and gate['zip'] is False and
|
||||
gate.get('missing_zip_reason'), 'historical attempt boundary')
|
||||
outcome = 'historically-closed-no-final-timestamp-no-input-ZIP'
|
||||
else:
|
||||
same_fields(before, result, ('phase', 'plan_sha256'))
|
||||
need(result['phase'] == gate['name'], 'gate phase mismatch')
|
||||
plan_bytes = get(gate['plan'])
|
||||
need(digest(plan_bytes) == result['plan_sha256'], 'gate plan mismatch')
|
||||
plan = decode(plan_bytes)
|
||||
need(plan['head'] == result['source'], 'gate plan source mismatch')
|
||||
outcome = step_sequence(result, plan['steps'], get, prefix, plan.get('stop_on_failure', True))
|
||||
need(gate['zip'] is True, 'unapproved missing source ZIP')
|
||||
if gate["zip"]:
|
||||
check_zip(get(prefix + "inputs.zip"), result["source_inputs"])
|
||||
matrix.append({"kind": "gate", "name": gate["name"], "source": result["source"],
|
||||
"receipt": prefix + "result.json", 'closure':outcome,
|
||||
"exits": [x["exit_code"] for x in result['steps']],
|
||||
"acceptance": "not inferred"})
|
||||
for name in config["native_cases"]:
|
||||
prefix = "navigation-native/" + safe(name) + "/"
|
||||
before = decode(get(prefix + "started.json"))
|
||||
result = decode(get(prefix + "finished.json"))
|
||||
same_fields(before, result, ('case','binary_source','binary_pins','pid','start','argv',
|
||||
'cwd','path','local_app_data'))
|
||||
need(result['case'] == name and type(result['pid']) is int and result['pid'] > 0
|
||||
and isinstance(result['argv'],list) and len(result['argv']) == 1, 'native launch identity')
|
||||
inventory(result['binary_pins'])
|
||||
need(set(result['binary_pins']) == {'arcadia-studio.exe','libunwind.dll'}, 'native binary pins')
|
||||
need(re.fullmatch('[0-9a-f]{40}', result['binary_source']), 'native source identity')
|
||||
if name == 'baseline01':
|
||||
same_fields(before,result,('fixture_sha256','release_tag'))
|
||||
hash_string(result['fixture_sha256'])
|
||||
need(result.get('fixture_unchanged') is True,'legacy fixture changed')
|
||||
else:
|
||||
same_fields(before,result,('original_fixtures',))
|
||||
inventory(result['original_fixtures'])
|
||||
need(result.get('original_fixtures_unchanged') is True,'native fixtures changed')
|
||||
need(result.get('binaries_unchanged') is True,'native binaries changed')
|
||||
need(type(result.get('exit_code')) is int and
|
||||
timestamp(result.get('finished')) >= timestamp(result['start']), 'native still open')
|
||||
streams(result,prefix,get,suffix='native')
|
||||
get(prefix + "observations.md")
|
||||
matrix.append({"kind": "native", "name": name, "source": result.get("binary_source"),
|
||||
"receipt": prefix + "finished.json", "binary_pins": result.get("binary_pins"),
|
||||
"exit": result["exit_code"], "acceptance": "not inferred"})
|
||||
for name in config["oracle_runs"]:
|
||||
prefix = "navigation-oracle-runs/" + safe(name) + "/"
|
||||
before = decode(get(prefix+'initial.json'))
|
||||
raw = get(prefix+'result.json')
|
||||
result = decode(raw)
|
||||
same_fields(before,result,('case','head','started','environment','qa_root','source_inputs'))
|
||||
need(result['case']==name,'oracle case mismatch')
|
||||
need(re.fullmatch('[0-9a-f]{40}',result['head']),'oracle source identity')
|
||||
inventory(result['source_inputs'])
|
||||
need(result.get('inputs_unchanged_except_initial_isolated_lock') is True,'oracle input drift')
|
||||
target = 'C:/Users/User/Documents/Git/ARCADIA-Studio/.local/target-gnullvm'
|
||||
planned = [
|
||||
{'label':'tests','command':f'cargo test --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir {target} -- --nocapture --test-threads=1'},
|
||||
{'label':'build','command':f'cargo build --locked --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir {target}'}]
|
||||
outcome = step_sequence(result,planned,get,prefix,True)
|
||||
final_lock = hash_string(result['final_utility_lock_sha256'])
|
||||
need(all(s['utility_lock_sha256']==final_lock for s in result['steps']), 'oracle lock drift')
|
||||
if name == 'attempt01':
|
||||
need(digest(raw)=='6523dd082eb02ad6f93130320fdfac4bcd3e320e61534a8ae1a54977537d17e7'
|
||||
and outcome=='stopped-at-first-failure','unknown historical oracle failure')
|
||||
else:
|
||||
need(outcome=='completed-successfully','oracle build not complete')
|
||||
hash_string(result['executable']['sha256'])
|
||||
need(type(result['executable']['bytes']) is int and result['executable']['bytes']>0,'oracle executable size')
|
||||
need(digest(get('navigation-oracle-qa-'+name+'/checks.jsonl'))==result['qa_checks_sha256'], 'oracle QA pin')
|
||||
matrix.append({'kind':'oracle-run','name':name,'source':result['head'],'closure':outcome,
|
||||
'receipt':prefix+'result.json','exits':[s['exit_code'] for s in result['steps']],
|
||||
'acceptance':'not inferred'})
|
||||
command_root = 'navigation-oracle-commands/'
|
||||
commands = sorted({p[len(command_root):].split('/')[0] for p in paths if p.startswith(command_root)})
|
||||
if not commands:
|
||||
return matrix
|
||||
build_path = 'navigation-oracle-runs/attempt02/result.json'
|
||||
build_raw = get(build_path)
|
||||
build = decode(build_raw)
|
||||
for name in commands:
|
||||
prefix = command_root+name+'/'
|
||||
safe(name)
|
||||
if name == 'draw-fixture-runtime':
|
||||
need({p for p in paths if p.startswith(prefix)} == {prefix+'failure.json'}, 'recorder failure files changed')
|
||||
failure_raw = get(prefix+'failure.json')
|
||||
failure = decode(failure_raw)
|
||||
need(digest(failure_raw)=='ecfa343366a1e156f9f20e9522cdc89f98352975b9100d3b7b2e6140fb4bafe3'
|
||||
and failure['oracle_started'] is False, 'unknown recorder failure')
|
||||
matrix.append({'kind':'oracle-command','name':name,'closure':'recorder-failed-before-launch',
|
||||
'receipt':prefix+'failure.json','acceptance':'none'})
|
||||
continue
|
||||
raw = get(prefix+'receipt.json')
|
||||
result = decode(raw)
|
||||
need(result['label']==name and type(result['exit_code']) is int and
|
||||
timestamp(result['finished'])>=timestamp(result['started']), 'open/misidentified oracle command')
|
||||
argv = result['argv']
|
||||
need(isinstance(argv,list) and all(isinstance(x,str) and x for x in argv) and
|
||||
len(argv)>=3 and argv[1] in ('fixture','snapshot','compare'), 'oracle argv')
|
||||
need(result['exe_unchanged'] is True and result['exe_sha256']==build['executable']['sha256']
|
||||
and result['build_receipt_sha256']==digest(build_raw), 'oracle build/executable binding')
|
||||
need(re.fullmatch('[0-9a-f]{40}',result['head']), 'oracle source identity')
|
||||
for stream in ('stdout','stderr'):
|
||||
size=result[stream+'_bytes']
|
||||
need(type(size) is int and size>=0 and len(get(prefix+stream))==size,'oracle stream size')
|
||||
if name == 'draw-fixture':
|
||||
need(digest(raw)=='89620b6726a510795a02bd173e0540c48610dbff0135915b0ce3ad81a6868818'
|
||||
and result['exit_code']==3221225781,'unknown runtime failure')
|
||||
outcome='runtime-load-failure-no-admission'
|
||||
else:
|
||||
need(result['exit_code']==0 and get(prefix+'stderr')==b'', 'oracle command failed: review/classify first')
|
||||
need(result['runtime_sha256']==config['oracle_runtime_sha256'] and
|
||||
result['path']=='C:\\Windows\\System32;C:\\Windows', 'oracle runtime environment')
|
||||
out=decode(get(prefix+'stdout'))
|
||||
if argv[1]=='snapshot':
|
||||
need(len(argv)==4 and out['status']=='captured', 'snapshot receipt status')
|
||||
dest=logical_argument(argv[3],config)
|
||||
snapshot=decode(get(dest+'/snapshot.json'))
|
||||
need(out['entry']==snapshot['entry'] and out['files']==len(snapshot['files']) and
|
||||
out['source_bytes']==sum(len(v) for v in snapshot['files'].values()) and
|
||||
out['engineering_diagnostics']==snapshot['diagnostics'], 'snapshot metadata binding')
|
||||
need(decode(get(dest+'/project.json'))==snapshot['project'], 'snapshot exported Project mismatch')
|
||||
elif argv[1]=='compare':
|
||||
need(len(argv) in (4,7) and out['pass'] is True and out['source_contract_pass'] is True
|
||||
and out['expected_entire_project_equal'] is True and
|
||||
out['raw_entry_and_path_set_equal'] is True,'compare status')
|
||||
for arg in argv[2:4]: get(logical_argument(arg,config)+'/snapshot.json')
|
||||
if len(argv)==4:
|
||||
need(out['permitted_projection'] is None and out['raw_source_bytes_equal'] is True,'exact compare scope')
|
||||
else:
|
||||
need(argv[4]=='--projection' and out['permitted_projection']=={'diagram':argv[5],'projection':argv[6]}
|
||||
and out['projection_declaration_diff']['outside_projection_value_exact'] is True,'projection compare scope')
|
||||
else:
|
||||
need(len(argv)==3 and out.get('status')=='fixture_written','fixture status')
|
||||
dest=logical_argument(argv[2],config)
|
||||
need(logical_argument(out['entry'],config)==dest+'/project.arcadia' and
|
||||
logical_argument(out['project_json'],config)==dest+'/project.json','fixture destination')
|
||||
get(dest+'/project.arcadia'); get(dest+'/project.json')
|
||||
outcome='recorded-success-metadata-checked-not-rerun'
|
||||
matrix.append({'kind':'oracle-command','name':name,'closure':outcome,'exit':result['exit_code'],
|
||||
'receipt':prefix+'receipt.json','acceptance':'not inferred'})
|
||||
return matrix
|
||||
|
||||
|
||||
def logical_argument(value, config):
|
||||
# Historical argv is data. This strips only a declared original root; never resolves/extracts it.
|
||||
value=value.replace('\\','/')
|
||||
for prefix in (config['original_source_local'].rstrip('/')+'/', '.local/'):
|
||||
if value.startswith(prefix):
|
||||
return safe(value[len(prefix):])
|
||||
raise ValueError('unknown historical source argument: '+value)
|
||||
|
||||
|
||||
def write_new(path, data):
|
||||
with path.open("xb") as file:
|
||||
file.write(data)
|
||||
|
||||
|
||||
def fresh_directory(path):
|
||||
for item in [path.parent, *path.parent.parents]:
|
||||
s = item.lstat()
|
||||
need(stat.S_ISDIR(s.st_mode) and not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "output parent link/reparse")
|
||||
path.mkdir(parents=False, exist_ok=False)
|
||||
|
||||
|
||||
def collect(source, config):
|
||||
entries, blobs = [], {}
|
||||
selection = selected(source, config)
|
||||
total = 0
|
||||
for name, category, file in selection:
|
||||
permitted(name)
|
||||
data = read(file)
|
||||
total += len(data)
|
||||
need(total <= MAX_TOTAL, 'logical packet size cap')
|
||||
sha = digest(data)
|
||||
entries.append({"path": name, "category": category, "size": len(data), "sha256": sha})
|
||||
blobs[sha] = data
|
||||
coverage(config, entries)
|
||||
return selection, entries, blobs
|
||||
|
||||
|
||||
def stable(source, config, selection, entries):
|
||||
need([(a,b) for a,b,_ in selected(source,config)] == [(a,b) for a,b,_ in selection],
|
||||
"input file set changed during collection")
|
||||
for entry in entries:
|
||||
need(digest(read(source / entry["path"])) == entry["sha256"], "input changed during collection")
|
||||
|
||||
|
||||
def freeze(config_path, source, frozen_path):
|
||||
# Creates only a candidate inventory. Human review/pinning must precede build.
|
||||
source=Path(source).absolute()
|
||||
config_bytes=read(Path(config_path).absolute())
|
||||
config=decode(config_bytes)
|
||||
validate_config(config)
|
||||
need('frozen_entries' not in config, 'already frozen')
|
||||
selection,entries,blobs=collect(source,config)
|
||||
index = {x["path"]: blobs[x["sha256"]] for x in entries}
|
||||
receipts(config,index.__getitem__,set(index))
|
||||
stable(source,config,selection,entries)
|
||||
config['frozen_entries']=entries
|
||||
config['template_sha256']=digest(config_bytes)
|
||||
config['inventory_review']='REQUIRED: inspect logical file list and content before build/publication'
|
||||
path=Path(frozen_path).absolute()
|
||||
for item in [path.parent,*path.parent.parents]:
|
||||
s=item.lstat()
|
||||
need(stat.S_ISDIR(s.st_mode) and not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s,'st_file_attributes',0)&0x400,'frozen output parent link/reparse')
|
||||
write_new(path,(json.dumps(config,indent=2)+'\n').encode())
|
||||
return {'frozen_config_sha256':digest(read(path)),'logical_files':len(entries),
|
||||
'review_required':True,'acceptance':'not inferred'}
|
||||
|
||||
|
||||
def build(config_path, source, output):
|
||||
source, output = Path(source).absolute(), Path(output).absolute()
|
||||
config_bytes = read(Path(config_path).absolute())
|
||||
config = decode(config_bytes)
|
||||
validate_config(config,frozen=True)
|
||||
selection,entries,blobs=collect(source,config)
|
||||
need(entries==config['frozen_entries'],'source differs from frozen inventory')
|
||||
index = {x["path"]: blobs[x["sha256"]] for x in entries}
|
||||
matrix = receipts(config, index.__getitem__,set(index))
|
||||
verifier = read(Path(__file__).absolute())
|
||||
need(index.get('navigation-evidence.py')==verifier,'running helper differs from frozen helper')
|
||||
fresh_directory(output)
|
||||
(output / "objects").mkdir()
|
||||
for sha, data in blobs.items():
|
||||
write_new(output / "objects" / sha, data)
|
||||
write_new(output / "navigation-evidence.py", verifier)
|
||||
write_new(output / "config.json", config_bytes)
|
||||
stable(source,config,selection,entries)
|
||||
manifest = {"schema": "navigation-evidence-v2", "entries": entries, "matrix": matrix,
|
||||
"config_sha256": digest(config_bytes), "verifier_sha256": digest(verifier),
|
||||
"documentation": config["documentation"], "acceptance": "not inferred"}
|
||||
write_new(output / "manifest.json", (json.dumps(manifest, indent=2) + "\n").encode())
|
||||
report = verify(output)
|
||||
# Read-only attribute is accidental-write protection, not tamper proofing.
|
||||
for file in walk_checked(output):
|
||||
if file.is_file():
|
||||
file.chmod(stat.S_IREAD)
|
||||
return report
|
||||
|
||||
|
||||
def verify(output):
|
||||
output = Path(output).absolute()
|
||||
manifest = decode(read(output / "manifest.json"))
|
||||
need(manifest["schema"] == "navigation-evidence-v2", "manifest schema")
|
||||
config_bytes = read(output / "config.json")
|
||||
need(digest(config_bytes) == manifest["config_sha256"], "config hash")
|
||||
need(digest(read(output / "navigation-evidence.py")) == manifest["verifier_sha256"], "verifier hash")
|
||||
config = decode(config_bytes)
|
||||
validate_config(config,frozen=True)
|
||||
need(manifest['entries']==config['frozen_entries'],'manifest differs from frozen whitelist')
|
||||
need(manifest['documentation']==config['documentation'] and manifest['acceptance']=='not inferred','manifest scope')
|
||||
coverage(config,manifest['entries'])
|
||||
names, objects, index = set(), set(), {}
|
||||
total=0
|
||||
for entry in manifest["entries"]:
|
||||
name = safe(entry["path"])
|
||||
need(name.casefold() not in names, "duplicate manifest path")
|
||||
need(not any(x.casefold() in EXCLUDED for x in PurePosixPath(name).parts), "excluded input")
|
||||
names.add(name.casefold())
|
||||
sha = entry["sha256"]
|
||||
need(isinstance(sha,str) and re.fullmatch("[0-9a-f]{64}", sha), "invalid digest")
|
||||
need(type(entry["size"]) is int and 0 <= entry["size"] <= MAX_FILE, "invalid size")
|
||||
total+=entry['size']
|
||||
need(total<=MAX_TOTAL,'logical size cap')
|
||||
data = read(output / "objects" / sha)
|
||||
need(len(data) == entry["size"] and digest(data) == sha, "object mismatch")
|
||||
index[name] = data
|
||||
objects.add("objects/" + sha)
|
||||
need(index.get('navigation-evidence.py')==read(output/'navigation-evidence.py'),'selfpin object mismatch')
|
||||
expected = objects | {"manifest.json", "config.json", "navigation-evidence.py"}
|
||||
actual = set()
|
||||
for file in walk_checked(output):
|
||||
s = file.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and not getattr(s,"st_file_attributes",0) & 0x400,
|
||||
"packet link/reparse")
|
||||
if file.is_file():
|
||||
actual.add(file.relative_to(output).as_posix())
|
||||
need(actual == expected, "extra or missing packet file")
|
||||
need(receipts(config, index.__getitem__,set(index)) == manifest["matrix"], "matrix mismatch")
|
||||
return {"integrity": "PASS", "logical_files": len(names), "objects": len(objects),
|
||||
"semantic_acceptance": "not evaluated", "manifest_sha256": digest(read(output / "manifest.json"))}
|
||||
|
||||
|
||||
def selftest(output):
|
||||
root = Path(output).absolute()
|
||||
fresh_directory(root)
|
||||
tests = []
|
||||
def reject(label, fn):
|
||||
try:
|
||||
fn()
|
||||
except (ValueError, FileNotFoundError, FileExistsError, KeyError, zipfile.BadZipFile):
|
||||
tests.append(label)
|
||||
return
|
||||
raise ValueError('falsifier unexpectedly accepted: '+label)
|
||||
source=root/'source'; source.mkdir()
|
||||
script=read(Path(__file__).absolute())
|
||||
for name,data in [('a.txt',b'evidence'),('b.txt',b'evidence'),('navigation-evidence.py',script)]:
|
||||
write_new(source/name,data)
|
||||
config={'schema':'navigation-evidence-config-v2','includes':[
|
||||
{'path':name,'category':'test','kind':'file'} for name in ('a.txt','b.txt','navigation-evidence.py')],
|
||||
'gates':[],'native_cases':[],'oracle_runs':[],'documentation':[],
|
||||
'exclusions':sorted(EXCLUDED),'original_source_local':'C:/example/.local',
|
||||
'oracle_runtime_sha256':'0'*64}
|
||||
template=root/'template.json'; write_new(template,json.dumps(config).encode())
|
||||
frozen=root/'frozen.json'; freeze(template,source,frozen)
|
||||
packet=root/'valid'; good=build(frozen,source,packet)
|
||||
need(good['logical_files']==3 and good['objects']==2,'dedup roundtrip')
|
||||
tests.append('valid-freeze-build-verify-dedup')
|
||||
reject('existing output refused',lambda:build(frozen,source,packet))
|
||||
reject('existing freeze refused',lambda:freeze(template,source,frozen))
|
||||
original=decode(read(packet/'manifest.json'))
|
||||
original_config=decode(read(packet/'config.json'))
|
||||
# Each falsifier gets its own create-new packet; valid packet stays read-only.
|
||||
for mutation in ('alter','delete','duplicate','traversal','extra','extra-listed','duplicate-json',
|
||||
'selfpin','omitted-required','unwhitelisted','category'):
|
||||
p=root/mutation; p.mkdir(); (p/'objects').mkdir()
|
||||
m=copy.deepcopy(original); cfg=copy.deepcopy(original_config)
|
||||
if mutation=='duplicate': m['entries'].append(dict(m['entries'][0]))
|
||||
if mutation=='traversal': m['entries'][0]['path']='../a.txt'
|
||||
if mutation=='extra-listed': m['entries'].append({'path':'absent.txt','category':'test','size':3,'sha256':'a'*64})
|
||||
if mutation=='omitted-required':
|
||||
m['entries']=[e for e in m['entries'] if e['path']!='a.txt']; cfg['frozen_entries']=copy.deepcopy(m['entries'])
|
||||
if mutation=='unwhitelisted':
|
||||
e=dict(m['entries'][0]); e['path']='unauthorized.txt'; m['entries'].append(e); cfg['frozen_entries']=copy.deepcopy(m['entries'])
|
||||
if mutation=='category':
|
||||
m['entries'][0]['category']='other'; cfg['frozen_entries']=copy.deepcopy(m['entries'])
|
||||
if mutation=='selfpin':
|
||||
m['verifier_sha256']=digest(b'changed')
|
||||
cb=json.dumps(cfg).encode(); m['config_sha256']=digest(cb)
|
||||
raw=json.dumps(m).encode()
|
||||
if mutation=='duplicate-json': raw=raw.replace(b'"schema":',b'"schema":"bad", "schema":',1)
|
||||
write_new(p/'manifest.json',raw); write_new(p/'config.json',cb)
|
||||
write_new(p/'navigation-evidence.py',b'changed' if mutation=='selfpin' else script)
|
||||
for obj in (packet/'objects').iterdir():
|
||||
if mutation=='delete' and obj.name==digest(b'evidence'): continue
|
||||
write_new(p/'objects'/obj.name,b'changed' if mutation=='alter' and obj.name==digest(b'evidence') else read(obj))
|
||||
if mutation=='extra': write_new(p/'extra.txt',b'extra')
|
||||
reject(mutation,lambda:verify(p))
|
||||
planned=[{'label':'one','command':'first'},{'label':'two','command':'second'}]
|
||||
closed={'started':'2026-01-01T00:00:00+00:00','finished':'2026-01-01T00:00:01+00:00',
|
||||
'exit_code':0,'stdout_bytes':0,'stderr_bytes':0}
|
||||
step=dict(closed,label='one',command='first')
|
||||
partial={'steps':[step]}
|
||||
reject('successful prefix still open',lambda:step_sequence(partial,planned,lambda _:b'','',True))
|
||||
complete={'steps':[step,dict(closed,label='two',command='second')]}
|
||||
reject('successful missing final timestamp',lambda:step_sequence(complete,planned,lambda _:b'','',True))
|
||||
complete['finished']=closed['finished']
|
||||
need(step_sequence(complete,planned,lambda _:b'','',True)=='completed-successfully','valid closure')
|
||||
failed={'steps':[dict(step,exit_code=101)]}
|
||||
need(step_sequence(failed,planned,lambda _:b'','',True)=='stopped-at-first-failure','failed prefix classification')
|
||||
tests.append('complete success and stopped failure distinguished')
|
||||
reject('stream size mismatch',lambda:step_sequence(complete,planned,lambda _:b'x','',True))
|
||||
reject('native/oracle identity mismatch',lambda:same_fields({'pid':1},{'pid':2},['pid']))
|
||||
reject('missing identity on both sides',lambda:same_fields({}, {}, ['binary_source']))
|
||||
reject('duplicate receipt/config JSON',lambda:decode(b'{"steps":[],"steps":[]}'))
|
||||
for constant in (b'NaN', b'Infinity', b'-Infinity'):
|
||||
reject('nonfinite JSON '+constant.decode(),lambda value=constant:decode(b'{"size":'+value+b'}'))
|
||||
reject('dot root path',lambda:safe('.'))
|
||||
native={'case':'baseline01','binary_source':'1'*40,'binary_pins':{'arcadia-studio.exe':'2'*64,'libunwind.dll':'3'*64},
|
||||
'pid':7,'start':closed['started'],'argv':['C:/private/bin/arcadia-studio.exe'],
|
||||
'cwd':'C:/private/working','path':'C:/Windows','local_app_data':'C:/private/profile',
|
||||
'fixture_sha256':'4'*64,'release_tag':'historical-test'}
|
||||
done=dict(native,finished=closed['finished'],exit_code=0,stdout_bytes=0,stderr_bytes=0,
|
||||
binaries_unchanged=True,fixture_unchanged=True)
|
||||
nc=dict(config,native_cases=['baseline01'])
|
||||
np='navigation-native/baseline01/'
|
||||
def native_records(result):
|
||||
data={np+'started.json':json.dumps(native).encode(),np+'finished.json':json.dumps(result).encode(),
|
||||
np+'stdout.log':b'',np+'stderr.log':b'',np+'observations.md':b'bounded selftest'}
|
||||
return receipts(nc,data.__getitem__,set(data))
|
||||
need(native_records(done)[0]['exit']==0,'legacy native fixture valid')
|
||||
for key,value in [('pid',8),('fixture_sha256','5'*64),('binaries_unchanged',False),('finished',None),('stdout_bytes',1)]:
|
||||
reject('native '+key,lambda k=key,v=value:native_records(dict(done,**{k:v})))
|
||||
# A command receipt must bind the included build object and its runtime/status.
|
||||
bp='navigation-oracle-runs/attempt02/result.json'; cp='navigation-oracle-commands/example/'
|
||||
bb=json.dumps({'executable':{'sha256':'6'*64}}).encode()
|
||||
oc=dict(config,oracle_runtime_sha256='7'*64)
|
||||
command={'label':'example','argv':['C:/oracle.exe','compare','.local/a','.local/b'],
|
||||
'started':closed['started'],'finished':closed['finished'],'exit_code':0,
|
||||
'exe_unchanged':True,'exe_sha256':'6'*64,'build_receipt_sha256':digest(bb),
|
||||
'head':'8'*40,'runtime_sha256':'7'*64,'path':'C:\\Windows\\System32;C:\\Windows',
|
||||
'stderr_bytes':0}
|
||||
command_output=json.dumps({'pass':True,'source_contract_pass':True,'expected_entire_project_equal':True,
|
||||
'raw_entry_and_path_set_equal':True,'permitted_projection':None,'raw_source_bytes_equal':True}).encode()
|
||||
command['stdout_bytes']=len(command_output)
|
||||
def command_records(result):
|
||||
data={bp:bb,cp+'receipt.json':json.dumps(result).encode(),cp+'stdout':command_output,cp+'stderr':b'',
|
||||
'a/snapshot.json':b'{}','b/snapshot.json':b'{}'}
|
||||
return receipts(oc,data.__getitem__,set(data))
|
||||
need(command_records(command)[0]['exit']==0,'command metadata valid')
|
||||
for key,value in [('build_receipt_sha256','9'*64),('runtime_sha256','9'*64),('exit_code',2),
|
||||
('finished',None),('stdout_bytes',0),('exe_unchanged',False)]:
|
||||
reject('oracle command '+key,lambda k=key,v=value:command_records(dict(command,**{k:v})))
|
||||
for name in ('../x','C:/x','a\\b','AUX.txt','a/../b','profile/x','auth/settings.json','x.exe'):
|
||||
reject('unsafe/private '+name,lambda n=name:permitted(n))
|
||||
for mutation in ('wrong-content','missing','extra','case-collision','traversal','symlink'):
|
||||
buf=io.BytesIO()
|
||||
with zipfile.ZipFile(buf,'w') as z:
|
||||
if mutation!='missing': z.writestr('a.txt',b'bad' if mutation=='wrong-content' else b'ok')
|
||||
if mutation=='extra': z.writestr('extra.txt',b'ok')
|
||||
if mutation=='case-collision': z.writestr('A.txt',b'ok')
|
||||
if mutation=='traversal': z.writestr('../x',b'ok')
|
||||
if mutation=='symlink':
|
||||
info=zipfile.ZipInfo('link'); info.create_system=3; info.external_attr=(stat.S_IFLNK|0o777)<<16
|
||||
z.writestr(info,b'a.txt')
|
||||
reject('ZIP '+mutation,lambda b=buf.getvalue():check_zip(b,{'a.txt':digest(b'ok')}))
|
||||
crc=io.BytesIO()
|
||||
with zipfile.ZipFile(crc,'w',compression=zipfile.ZIP_STORED) as z:
|
||||
z.writestr('a.txt',b'crc-evidence')
|
||||
damaged=bytearray(crc.getvalue()); at=damaged.index(b'crc-evidence'); damaged[at]^=1
|
||||
reject('ZIP CRC corruption',lambda:check_zip(bytes(damaged),{'a.txt':digest(b'crc-evidence')}))
|
||||
# Real filesystem link test when the platform permits unprivileged link creation.
|
||||
skipped=[]
|
||||
link=root/'linked-source'
|
||||
try:
|
||||
link.symlink_to(source,target_is_directory=True)
|
||||
except (OSError,NotImplementedError) as error:
|
||||
skipped.append('filesystem reparse/symlink creation unavailable: '+type(error).__name__)
|
||||
else:
|
||||
reject('filesystem link ancestor',lambda:read(link/'a.txt'))
|
||||
reject('output link ancestor',lambda:fresh_directory(link/'new'))
|
||||
# Changed source after freeze must fail before packet creation.
|
||||
(source/'a.txt').write_bytes(b'changed')
|
||||
reject('source changed since freeze',lambda:build(frozen,source,root/'changed-source'))
|
||||
report = {"selftest":"PASS", "cases":tests,'skipped':skipped,"scope":"transport integrity only; skipped controls are not passes"}
|
||||
write_new(root / "result.json", (json.dumps(report,indent=2)+"\n").encode())
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mode, *args = sys.argv[1:]
|
||||
need(mode in ("freeze", "build", "verify", "selftest"), "unknown mode")
|
||||
print(json.dumps({"freeze":freeze,"build":build,"verify":verify,"selftest":selftest}[mode](*args),indent=2))
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\peer01\\bin\\arcadia-studio.exe",
|
||||
"id": 25822524,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough — Arcadia Studio"
|
||||
},
|
||||
"accessibility": {
|
||||
"focused_element": "0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise",
|
||||
"tree": "Window: \"Restore safe water pressure — release 1 walkthrough — Arcadia Studio\", App: arcadia-studio.exe.\n\t0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise\n\t\t1 title bar (settable, string) Value: Restore safe water pressure — release 1 walkthrough — Arcadia Studio\n\t\t\t2 menu bar System ID: MenuBar\n\t\t\t\t3 menu item System\n\t\t\t4 button Minimize\n\t\t\t5 button Maximize\n\t\t\t6 button Close\n\nThe focused UI element is 0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise."
|
||||
},
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "04-before-oa-edge-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "f267facc804d62ea065ae970b7315cc0a5e580dbf726a291626a354f923adf54"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,12 @@
|
||||
sa_function sa_target "SA execute repair" id "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3" {
|
||||
}
|
||||
|
||||
sa_port functional_exchange_sa_dispatch_repair_sa_execute_repair_input "Functional exchange: SA dispatch repair → SA execute repair input" id "0a6f4b4b-911c-531d-82d5-172bdc8b0564" {
|
||||
contract {
|
||||
FunctionInput {
|
||||
incoming_items [];
|
||||
};
|
||||
};
|
||||
parent @sa_target;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,60 @@
|
||||
sa_scenario sa_conversation "Repair request and measured reply" id "730b27ca-b039-5a3b-8dc6-03418f9061b3" {
|
||||
context @sa_restore;
|
||||
postcondition "The controller has received the measured pressure after repair";
|
||||
precondition "A pressure alarm has been assessed";
|
||||
steps [@sa_call, @sa_reply];
|
||||
viewpoint BoundaryInterface;
|
||||
}
|
||||
|
||||
sa_occurrence sa_call "Request valve repair" id "8bab4783-a098-5984-9e36-493957caf26c" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @sa_contact;
|
||||
message_kind Call;
|
||||
receiver @sa_responder;
|
||||
sender @sa_caller;
|
||||
values [{
|
||||
expression "420.0";
|
||||
field @sa_pressure_parameter;
|
||||
}];
|
||||
};
|
||||
};
|
||||
parent @sa_conversation;
|
||||
}
|
||||
|
||||
sa_lifeline sa_caller "Controller" id "98c61788-2fee-51f4-8cf9-de48e8c70e9e" {
|
||||
parent @sa_conversation;
|
||||
represented @sa_control;
|
||||
}
|
||||
|
||||
sa_occurrence sa_reply "Return pressure after repair" id "4e1c0a56-ce80-5f13-a93a-6956de54880c" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @sa_contact;
|
||||
message_kind Reply;
|
||||
receiver @sa_caller;
|
||||
reply_to @sa_call;
|
||||
sender @sa_responder;
|
||||
values [{
|
||||
expression "320.0";
|
||||
field @sa_pressure_parameter;
|
||||
}];
|
||||
};
|
||||
};
|
||||
parent @sa_conversation;
|
||||
}
|
||||
|
||||
sa_lifeline sa_responder "Technician" id "6249b54a-e396-5338-82b9-89266c55670d" {
|
||||
parent @sa_conversation;
|
||||
represented @sa_technician;
|
||||
}
|
||||
|
||||
artifact repair_test_evidence "Repair conversation acceptance notes" id "f5922bf7-05d8-590f-86e9-1d09f37f4fa6" {
|
||||
description "A local teaching artifact, not proof of a real valve's safety.";
|
||||
fragment "acceptance";
|
||||
owner @sa_conversation;
|
||||
relation Verifies;
|
||||
revision "lesson-1";
|
||||
uri "evidence/repair-check.md";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "04-sa-draw-armed-oa-retained-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "1aea20c087a78156ab476ce8269edd295a906a86cd45476147f8483363cab690"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "05-wrong-layer-target-refused-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "a73be6efe78cc071da3c4ee352a11023689cbb8ddf0632121be40dd920d9e574"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
oa_activity oa_source "OA request repair" id "e4a68a7d-6949-5c17-9263-c03c03c21af2" {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"case": "relocation01-reopen",
|
||||
"binary_source": "6018ae30b9296b9f6b37d7fc038175080d15fafb",
|
||||
"start": "2026-10-02T06:55:10.845877+00:00",
|
||||
"binary_pins": {
|
||||
"arcadia-studio.exe": "f86b29555d1e512538ff3f7ee75619b0286add5aa2d60cdc724e8c27cc931165",
|
||||
"libunwind.dll": "f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4"
|
||||
},
|
||||
"original_fixtures": {
|
||||
"eight-card.json": "9db1015369f450d5810c96a01fddb8a009d57f6aafbda5182703f15ded23d9c8",
|
||||
"draw/oracle.json": "b3eb669d353bf9f3490faf50cdf65b9f46d77719de993557c56ea159d4696ae0",
|
||||
"draw/project.arcadia": "2a657d69cf30487364672cd45d0a0fa01a78d64140cc58eeee47e61ea4a112d4",
|
||||
"draw/project.json": "520e5645bcf87380a50be08cb9ef758b85a19414e58ce3bd61926c09767a7963",
|
||||
"draw/views/diagram_mixed_oa.arcadia": "d481e137236c62b13afebae3b14d6eedb61c27b7f6566cb3f6dc325d4aaea927",
|
||||
"draw/views/diagram_mixed_sa.arcadia": "b5c326eafea071ed0f058b8a792271c2bb83c87eab6b7718a51a9cd3782d449d",
|
||||
"draw/oa/activities/activity_oa_source.arcadia": "4d8b0971cc64a3ae13f7bf4b0c79a5ee978facb8ee660c2690c473b0b935ba5f",
|
||||
"draw/oa/activities/activity_oa_target.arcadia": "afef0fbac3fb7fbe42936a0c213e72541b31a471af8257a9462bdafb1f39c8ab",
|
||||
"draw/sa/functions/function_sa_source.arcadia": "536de40f46f64c745af0a41a8dfa70309782e90617be69dd37610cb4e9730ed2",
|
||||
"draw/sa/functions/function_sa_target.arcadia": "cb6d97f9b3c112f9b50bb9bb018ce0e6e34d51c8ae53afd6d6493367f371f1fa",
|
||||
"relocation-fixture.json": "282a3ef1206000c1bd8d32c55bde950831806096fbfcec7c324e182a49d7df4c"
|
||||
},
|
||||
"argv": [
|
||||
"C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\bin\\arcadia-studio.exe"
|
||||
],
|
||||
"cwd": "C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\working",
|
||||
"path": "C:\\Windows\\System32;C:\\Windows",
|
||||
"local_app_data": "C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\profile",
|
||||
"pid": 6776
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"label": "draw01-dataflow",
|
||||
"argv": [
|
||||
"C:\\ar-nav-01a0d717\\.local\\navigation-oracle-runtime\\navigation-oracle.exe",
|
||||
"snapshot",
|
||||
"C:/ar-nav-01a0d717/.local/navigation-native/draw01/working/draw-save/project.arcadia",
|
||||
"C:/ar-nav-01a0d717/.local/navigation-native/draw01/dataflow"
|
||||
],
|
||||
"started": "2026-10-02T06:31:09.966755+00:00",
|
||||
"exe_sha256": "bd2db2e177d9d21f44b45e5efa89071e00155d31509672bf5aea6a57915e09a6",
|
||||
"runtime_sha256": "f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4",
|
||||
"path": "C:\\Windows\\System32;C:\\Windows",
|
||||
"head": "c7065db76ecca3faa733ccb5f09b87fd0a0958be",
|
||||
"build_receipt_sha256": "1ed50fe9c42ff4a67084ed70ce934404f5549ca68bc54e6d2f1436e66ea8d22f",
|
||||
"exit_code": 0,
|
||||
"finished": "2026-10-02T06:31:10.897201+00:00",
|
||||
"stdout_bytes": 7336,
|
||||
"stderr_bytes": 0,
|
||||
"exe_unchanged": true
|
||||
}
|
||||
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,17 @@
|
||||
oa_exchange_category oa_contact_category "Field contact" id "19bd3a3a-4d0c-56d5-bf50-a6b43fd81771" {
|
||||
exchanges [@oa_contact];
|
||||
family "Participant";
|
||||
}
|
||||
|
||||
oa_event oa_high_pressure "Pressure alarm detected" id "65d6b8d6-271a-5c84-8b42-abbe310c955e" {
|
||||
body {
|
||||
Change {
|
||||
expression "measured_pressure > operating_limit";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
oa_exchange_category oa_repair_traffic "Repair work exchanges" id "9349b353-53f2-56f8-b571-a05b09335cdf" {
|
||||
exchanges [@oa_repair_request, @oa_repair_report];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"head": "6018ae30b9296b9f6b37d7fc038175080d15fafb",
|
||||
"steps": [
|
||||
{"label":"native-bins","command":"cargo build --workspace --bins --locked"}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\fixed01-reopen\\bin\\arcadia-studio.exe",
|
||||
"id": 17957882,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "05-reopen-saved-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "ab1fde5b53c66d227235610ab3aed1069ab86a9d70e4b10e18148d6275e5f925"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"schema_version": 7,
|
||||
"id": "01324fcc-9456-5341-8fd0-2c261bddf186",
|
||||
"alias": "navigation_draw",
|
||||
"name": "Cross-layer native Draw verification",
|
||||
"description": "Synthetic native acceptance fixture; four complete typed work elements, two saved mixed-layer views, initially no relations.",
|
||||
"elements": {
|
||||
"0a6f4b4b-911c-531d-82d5-172bdc8b0564": {
|
||||
"id": "0a6f4b4b-911c-531d-82d5-172bdc8b0564",
|
||||
"alias": "functional_exchange_sa_dispatch_repair_sa_execute_repair_input",
|
||||
"name": "Functional exchange: SA dispatch repair → SA execute repair input",
|
||||
"description": "",
|
||||
"parent": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionInput": {
|
||||
"incoming_items": []
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"7957e536-d4b4-50c2-8271-ef829952353f": {
|
||||
"id": "7957e536-d4b4-50c2-8271-ef829952353f",
|
||||
"alias": "functional_exchange_sa_dispatch_repair_sa_execute_repair_output",
|
||||
"name": "Functional exchange: SA dispatch repair → SA execute repair output",
|
||||
"description": "",
|
||||
"parent": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionOutput": {
|
||||
"outgoing_items": []
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"7b9d5c34-1c45-5048-a3a0-77e5784a5a69": {
|
||||
"id": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"alias": "oa_target",
|
||||
"name": "OA perform repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
}
|
||||
},
|
||||
"c5e2f9d6-ba03-50b6-9860-b2dd717c02b3": {
|
||||
"id": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"alias": "sa_target",
|
||||
"name": "SA execute repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"e4a68a7d-6949-5c17-9263-c03c03c21af2": {
|
||||
"id": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"alias": "oa_source",
|
||||
"name": "OA request repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
}
|
||||
},
|
||||
"fa0825f4-ae33-5eb3-9222-bf3a6465e72e": {
|
||||
"id": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"alias": "sa_source",
|
||||
"name": "SA dispatch repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"50e20de1-197f-4126-90da-81c6e4c93409": {
|
||||
"id": "50e20de1-197f-4126-90da-81c6e4c93409",
|
||||
"alias": "functional_exchange_sa_dispatch_repair_sa_execute_repair",
|
||||
"name": "Functional exchange: SA dispatch repair → SA execute repair",
|
||||
"description": "",
|
||||
"body": {
|
||||
"FunctionalExchange": {
|
||||
"source": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"target": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"source_port": "7957e536-d4b4-50c2-8271-ef829952353f",
|
||||
"target_port": "0a6f4b4b-911c-531d-82d5-172bdc8b0564",
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"a425a1af-e16f-4e25-9fb7-9f063dbe2fda": {
|
||||
"id": "a425a1af-e16f-4e25-9fb7-9f063dbe2fda",
|
||||
"alias": "interaction_oa_request_repair_oa_perform_repair",
|
||||
"name": "Interaction: OA request repair → OA perform repair",
|
||||
"description": "",
|
||||
"body": {
|
||||
"Interaction": {
|
||||
"source": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"target": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagrams": {
|
||||
"a60ffb09-36c2-5abc-bec5-6eab563c8cb8": {
|
||||
"id": "a60ffb09-36c2-5abc-bec5-6eab563c8cb8",
|
||||
"alias": "mixed_oa",
|
||||
"name": "OA mixed Draw canvas",
|
||||
"description": "",
|
||||
"layer": "Oa",
|
||||
"projection": "Traceability",
|
||||
"view_options": {
|
||||
"hidden_edges": [],
|
||||
"relation_kinds": "All",
|
||||
"work_categories": "All",
|
||||
"participant_categories": "All",
|
||||
"coverage": "All",
|
||||
"work_contexts": "All",
|
||||
"exchange_labels": "Name"
|
||||
},
|
||||
"nodes": {
|
||||
"1f2bb997-b4af-5910-b91b-1948764cff4f": {
|
||||
"id": "1f2bb997-b4af-5910-b91b-1948764cff4f",
|
||||
"alias": "mixed_oa_oa_target",
|
||||
"element": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"371bfc51-edcc-5770-aad1-11b84772d2c8": {
|
||||
"id": "371bfc51-edcc-5770-aad1-11b84772d2c8",
|
||||
"alias": "mixed_oa_oa_source",
|
||||
"element": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"752822e9-1f95-5e9c-b1d3-58c325d875fa": {
|
||||
"id": "752822e9-1f95-5e9c-b1d3-58c325d875fa",
|
||||
"alias": "mixed_oa_sa_source",
|
||||
"element": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"eb2ce67e-c283-5689-8780-b54e1b06d7cc": {
|
||||
"id": "eb2ce67e-c283-5689-8780-b54e1b06d7cc",
|
||||
"alias": "mixed_oa_sa_target",
|
||||
"element": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
"routes": []
|
||||
},
|
||||
"aa64b55c-9bbb-5444-bda4-c9be0c66242a": {
|
||||
"id": "aa64b55c-9bbb-5444-bda4-c9be0c66242a",
|
||||
"alias": "mixed_sa",
|
||||
"name": "SA mixed Draw canvas",
|
||||
"description": "",
|
||||
"layer": "Sa",
|
||||
"projection": "Traceability",
|
||||
"view_options": {
|
||||
"hidden_edges": [],
|
||||
"relation_kinds": "All",
|
||||
"work_categories": "All",
|
||||
"participant_categories": "All",
|
||||
"coverage": "All",
|
||||
"work_contexts": "All",
|
||||
"exchange_labels": "Name"
|
||||
},
|
||||
"nodes": {
|
||||
"44169450-37f5-59fd-b9ab-32482c943f99": {
|
||||
"id": "44169450-37f5-59fd-b9ab-32482c943f99",
|
||||
"alias": "mixed_sa_sa_target",
|
||||
"element": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"a0a18bd6-891a-56a4-a46d-d7b7d9911fc9": {
|
||||
"id": "a0a18bd6-891a-56a4-a46d-d7b7d9911fc9",
|
||||
"alias": "mixed_sa_oa_target",
|
||||
"element": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"aca2f538-69bf-561f-9eff-433febee5234": {
|
||||
"id": "aca2f538-69bf-561f-9eff-433febee5234",
|
||||
"alias": "mixed_sa_sa_source",
|
||||
"element": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"f5e4244f-e1c4-59ec-9109-be1e851386c1": {
|
||||
"id": "f5e4244f-e1c4-59ec-9109-be1e851386c1",
|
||||
"alias": "mixed_sa_oa_source",
|
||||
"element": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
"routes": []
|
||||
}
|
||||
},
|
||||
"artifact_links": {},
|
||||
"traces": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"recorded_at": "2026-10-02T07:10:23.939491+00:00",
|
||||
"command": [
|
||||
"C:/Users/User/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe",
|
||||
".local/navigation-evidence.py",
|
||||
"selftest",
|
||||
".local/navigation-evidence-selftest-attempt01"
|
||||
],
|
||||
"working_directory": "C:/ar-nav-01a0d717",
|
||||
"tool_exit_code": 0,
|
||||
"helper_sha256": "3b2c6c59c895dd2e40b5dc408e68fe7c3760447c62a7f98413db60ea18ec7825",
|
||||
"result_sha256": "4ef9a80a3de9bc60e21804f2d2ed51d77a78474c5e23f855b06ea5ce25e7cebb",
|
||||
"result_reported_cases": 52,
|
||||
"skipped": [
|
||||
"filesystem reparse/symlink creation unavailable: OSError"
|
||||
],
|
||||
"scope": "Parent executed after independent candidate review. Recorded now from returned tool exit and retained actual result; no fabricated command start/end timestamps. Transport only."
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Independent native subset evidence review
|
||||
|
||||
2026-10-02 UTC; reviewer diagram_reading (Astra). Root C:/ar-nav-01a0d717. Read-only review of fixed01, fixed01-reopen and draw01 notes, process receipts, original JPEGs/state manifests, and selected saved-data oracle receipts. No GUI control, Cargo, source/model or Gitea changes; only this note was written. This is evidence review of the existing private c706 run, not a new native run or release acceptance.
|
||||
|
||||
## Finding requiring a wording correction
|
||||
|
||||
The draw01 observation that the OA Interaction label became fully visible at 96% overstates the display. Capture16 shows its label clipped at the top canvas boundary at 113%. Captures28,31,33 show its label box back inside the viewport at 96%, but the text is still ellipsized as Interaction: OA request repair .... The functional-exchange label is also ellipsized in 09 and 33. Say that refitting resolved viewport clipping while full label text remained truncated. This matters to the still-open small-diagram readability requirement; successful creation does not establish readable labels.
|
||||
|
||||
The expanded palette also visibly clips both right-side card contents at 113% in captures25–27. At 96% after perspective navigation, capture28 contains all four cards within the narrower canvas. The screenshots support this width/camera limitation; they cannot prove that authored placements were unchanged. The final saved-data comparator supplies the separate conservation evidence.
|
||||
|
||||
No other material contradiction found in the requested bounded subset. Do not broaden these results to source acceptance: the notes explicitly identify c706's failed workspace placement-relocation regression. This review does not rerun or overturn that failure or accept a later repair.
|
||||
|
||||
## Directly inspected original captures
|
||||
|
||||
All images below were opened individually through view_image at their original JPEG paths; no regenerated diagrams or crops were used.
|
||||
|
||||
- draw01: 04,05,06,07,08,09,16,21,22,23,25,26,27,28,31,33,34,35 (all numbered -0.jpg).
|
||||
- fixed01: 06-oa-edge-inspected, 12-valid-apply-retains-mixed, 17-valid-draft-click-oa-process, 22-allocation-switch-succeeds.
|
||||
- fixed01-reopen: 01-reopened-source-1 (owned warning), 04-reopened-allocation-0, 06-reverse-traceability-0, 09-explicit-open-endpoint-0.
|
||||
|
||||
Draw04 visibly retains Operational and OA mixed Draw canvas while the Inspector identifies SA dispatch repair / sa_source. The source is green and the compatible SA execute card is outlined blue; OA cards are not candidate-highlighted. Draw05 retains that view and shows Choose a highlighted compatible endpoint after the pointer is on the OA card, with a temporary dashed preview. It supports refusal feedback, not a semantic mutation claim. Draw06 shows both SA functions as possible sources and the source-selection hint, consistent with a blank click resetting source choice while keeping the tool. Draw07 shows Select/move and Drawing cancelled; 08 shows Nothing to undo. A screenshot of that status cannot independently count history entries.
|
||||
|
||||
Draw09 shows the Functional exchange between the two SA cards, triangle port glyphs, matching source/target names in Properties, created feedback, and the same OA canvas. Draw16 shows System and SA mixed Draw canvas while Properties identifies an Interaction from OA request repair to OA perform repair (UUID a425a1af-e16f-4e25-9fb7-9f063dbe2fda). The green OA line and existing lower SA line are visible. Exact automatic port identities, contracts, record counts and unchanged records belong to the separately reviewed saved-Project evaluator, not visual inference.
|
||||
|
||||
Draw21 has the Model explorer tab active and its source context menu has no Draw entry. Draw22 has Architecture active with the same sidebar object menu and includes Draw functional exchange. Draw23 confirms the latter starts from sa_source on the retained SA canvas, with the target/port highlighted. Documentation should say Architecture sidebar, not generically Model explorer. This review confirms the visible distinction, not that a later documentation change is already shipped.
|
||||
|
||||
Draw25 shows expanded SA Functional exchange enabled, Boundary exchange and Allocation greyed. Draw26 leaves Select/move active and displays the Boundary exchange compatible-source tooltip. Draw27 activates Functional exchange, adds its source-selection hint and blue compatible SA outlines. Draw28 shows the OA labels/glyphs; Interaction, Communication means and Allocation are visually distinct at this scale, but this is not evidence that an unaided person understands each icon. Draw33/34 show compact OA controls and the disabled Communication means guidance. Draw35 shows the compact Interaction selected and only OA cards highlighted as sources. SA compact selected glyph state is visible in contextual/sidebar capture23; that is not an independent direct click test of the compact SA glyph. The notes distinguish that from the separately clicked expanded control.
|
||||
|
||||
Draw31 shows Functional dataflow, just the two OA cards and their line, and the reduced palette (Activity, Interaction, Add existing; no participant/Communication/Allocation controls). Hidden SA records cannot be inferred as conserved from this image. The recorded projection comparator is the relevant saved-data evidence.
|
||||
|
||||
Fixed01 captures06/12 keep System and the named mixed Traceability view while the OA Chain involvement alias oa_assessment_delivery is inspected/renamed, with UUID 628cb4fc-fe63-5f6b-b364-6268f26b5740. Capture17 shows the OA process selected, Properties applied feedback, and the same canvas. Capture22 shows six SA cards in allocated nesting and Inspector projection Allocation; OA process/capability are absent from the canvas. Fixed01-reopen04 repeats that allocation rendering; 06 restores eight-card traceability. Reopen09 intentionally navigates to Operational and selects oa_assessment_procedure after the explicit endpoint action. This complements inspection retention rather than contradicting it.
|
||||
|
||||
The owned warning JPEG confirms Windows rejected the forward-slash filename before model admission. The detailed Return/UIA recovery sequence is recorded in the observer's note; a still JPEG alone does not independently prove each failed input call. The reviewed draw04 state JSON has accessibility:null, consistent with the tool's limited semantic exposure. Neither that fact nor pointer success establishes screen-reader support, complete Tab navigation, focus semantics, or keyboard-only authoring. Escape/Undo/Redo are bounded exercised keyboard paths; pan hints visible in the UI do not establish that keyboard pan was tested.
|
||||
|
||||
## Process, logs, saved-state evidence and limits
|
||||
|
||||
All three started/finished pairs identify c7065db76ecca3faa733ccb5f09b87fd0a0958be and GUI a9d8b2fe2eecb66e17cd16ab2efe6c270e70588d0282fe2ac5d30e50f827b8d8 with DLL f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4, system-only PATH and separate profile paths. Finished receipts record exit0, unchanged copied binaries/original fixtures; actual stdout.log and stderr.log files are zero bytes for each case. PIDs are fixed01 9668, reopen 9384, draw01 8736. Fresh-process attribution comes from these process records and action notes, not similar screenshots. There is no fresh-process Draw reopen claim.
|
||||
|
||||
Read the fixed01 preparation/inspection/name-undo/implicit-undo/projection-undo/projection-redo exact compare receipts, fixed01-reopen/reverse conservation, draw01 final and dataflow comparison receipts. All inspected command receipts have exit0 and empty actual stderr; comparison outputs pass. Exact comparisons report complete expected Project and raw source-byte equality. Projection comparisons report only the nominated view change and compiler-indexed token replacement, with exact outside-token bytes. The name/implicit saved-delta reports identify the one relation and expected name; their differing evaluator attribution is correctly disclosed in observations.md. The draw delta reports pin helper 6ac2c230... and expressly limit source coverage to inventories. The earlier independent helper review remains .local/native-draw-evaluator-review.md.
|
||||
|
||||
These are existing receipts, not executions performed by this reviewer. Screenshots alone do not establish complete Project conservation, invisible source bytes, one-step transaction history, no unsaved RAM changes, or fresh-process persistence. Counts and warning counts likewise do not establish those facts. Do not infer performance from this run: notes disclose concurrent compiler activity. Synthetic fixtures are useful bounded cases, not recovered user models or comprehensive engineering projects.
|
||||
|
||||
## Evidence pins
|
||||
|
||||
SHA-256 of the reviewed observation/process files:
|
||||
|
||||
| Case | observations.md | started.json | finished.json |
|
||||
| --- | --- | --- | --- |
|
||||
| fixed01 | 7c75d9e04772c35d5084243216caee2781fcebdb24d569c5652de70d554f2403 | f202e806fc935cc43bbf5f53bd64b8cae2627f84785e679c39d4b8b1de63114a | 45c59211f3ce4c78b2dabd93f34b31dec8abbfee0c49a6e553c4a1d3610cf23c |
|
||||
| fixed01-reopen | d6d5724bdbdb02b917b435b7668888886420b31dd8af5ef2d6746c37ca5f9596 | b386c60c93e02883e7b32ec679596f454d1a4f4e23fd799200928b694f4c7bb6 | fdd86e93bd8740245fd0d9e7d0bbe6981b1e7b98b82cdbf8ea2923a13c22bae7 |
|
||||
| draw01 | 52b21b0f80a39808949c7afa531b934528a4069521d04b2e24303fa00ca6a57e | f17a7aa2cc3bc1719e718a40099f38b0b3a8c5c5529e95a8e0c78a71f102d019 | f75847d4d1ad96ee489256e0549ea4569537d4fae53f7659cd5479dab05e017f |
|
||||
|
||||
Read-only hash calculations verified every JPEG against its adjacent original capture manifest: fixed01 28/28, fixed01-reopen 11/11 (including warning image), draw01 38/38. No mismatches. For example draw04 SHA is 1aea20c087a78156ab476ce8269edd295a906a86cd45476147f8483363cab690; its manifest reports 1442 x 932 and the correct private draw01 executable path. The individual manifests remain the authoritative full capture-pin inventory.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Isolated navigation oracle
|
||||
|
||||
Prepared against core commit `6520a89dc042dd433454f6cc63cb23b0e6df7911`. This local crate is not a production CLI feature or workspace member. It has not been compiled or executed by its author. The initial preparation made no generated fixture/admission claim; parent execution evidence and the tested Windows runtime recipe are appended below. The prior `.local/populated-projection-regression.patch` is unchanged.
|
||||
|
||||
## Parent build and first admission
|
||||
|
||||
From `C:/ar-nav-01a0d717`, after the exclusive frozen compiler gate permits it:
|
||||
|
||||
```powershell
|
||||
cargo build --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir .local/navigation-oracle/target
|
||||
```
|
||||
|
||||
The utility's Cargo.lock is initially an exact copy of the repository lock, used as a dependency seed. The first isolated build may update that local lock for this new package. Preserve its final bytes and the binary hash with the evidence. Subsequent reproducible builds should add `--locked --offline`. No Cargo command has been run in this task. Use Rust 1.90 or later, matching the repository's declared minimum. Dependencies are the repository path `oa-core` plus the same serde/serde_json major requirements already in the repository; no Python, shell helper or external server is required by the utility. The tested Windows gnullvm executable does require its matching libunwind.dll beside the executable; see the standalone runtime recipe below.
|
||||
|
||||
The `reviewed_core` field is a source-review pin, not an automatic runtime fingerprint. Record actual repository HEAD, tracked diff, utility hashes, final lock and executable hash when building. A later parent core change must not be attributed to this pin.
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
$oracle = 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime/navigation-oracle.exe'
|
||||
& $oracle fixture C:/ar-nav-01a0d717/.local/native-draw-fixture
|
||||
& $oracle snapshot C:/path/to/saved/project.arcadia C:/path/to/new-baseline-snapshot
|
||||
& $oracle snapshot C:/path/to/saved/project.arcadia C:/path/to/new-after-snapshot
|
||||
& $oracle compare C:/path/to/new-baseline-snapshot C:/path/to/new-after-snapshot
|
||||
& $oracle compare C:/path/to/new-baseline-snapshot C:/path/to/new-after-snapshot --projection 00000000-0038-4000-8000-000000000001 Allocation
|
||||
```
|
||||
|
||||
Every output directory must be new, with an existing parent. Existing evidence is never overwritten. Fixture/snapshot writes that fail may leave partial output; preserve the failure, then choose another new directory. `compare` writes a JSON report to stdout and does not modify either snapshot. Exit 0 means that command completed successfully or comparison passed; 1 means a completed comparison failed; 2 means usage, loading, admission, IO or snapshot consistency failed. Capture stderr and the exit code as well as stdout.
|
||||
|
||||
For the projection trial: exact `compare` for selection/Properties; nominated-diagram `Allocation` comparison for switched state; exact baseline-versus-Undo, switched-versus-Redo and switched-versus-fresh-reopen comparisons. The utility does not infer which diagram changed. It never excuses unrelated fields, other diagrams, ID changes or reference changes.
|
||||
|
||||
## Snapshot contract
|
||||
|
||||
`snapshot` uses core `source_files::load_project` with the CLI's explicit policy: 64 MiB aggregate source, 256 files, include depth 32, 1024 include edges, 100000 graph objects, 250000 records and 500000 ordered references. It rejects only discovery/admission diagnostics whose actual enum is `validation::Severity::Error`, and returns full located compiler diagnostics on admission failure. Engineering warnings from successful compilation are retained in the report/snapshot. It compiles the reachable bundle with `source_project::compile_bundle` and exports the complete accepted Project to `project.json`.
|
||||
|
||||
`snapshot.json` contains the accepted Project, logical entry name and every reachable source path mapped to its exact raw captured disk bytes as an integer array. It preserves CR/LF, whitespace, comments and UTF-8 bytes; source is not regenerated or normalized. Disk expectations are checked against compiler input. Two complete source captures must match before evidence is written. This detects changes across the two observations but is not filesystem locking or an atomic multi-file snapshot; keep the saved project quiet.
|
||||
|
||||
The core loader rejects unsafe include paths, symlinks/junction escapes and invalid UTF-8. No snapshot logical path is used to write a file. Comparison revalidates logical paths, aggregate limits and UTF-8, recompiles all raw source, and requires that embedded Project and stored diagnostic strings exactly equal fresh compilation results. Compare reports diagnostics from that fresh compilation. Duplicate JSON source-path keys are rejected by a small serde visitor matching the core SourceBundle boundary pattern. Snapshot input itself is capped at 512 MiB. Source requiring UUID stabilization or migration is rejected so compiler-added text cannot be mistaken for original disk bytes: explicitly save the accepted current-version source first. Detached/unreachable editor buffers and unrelated disk files are outside this saved-project oracle.
|
||||
|
||||
## Comparison contract
|
||||
|
||||
Without an allowance, comparison requires complete Project equality, identical logical entry/path set and byte equality for every captured source file.
|
||||
|
||||
With `--projection DIAGRAM_UUID ProjectionKind`, clone the complete baseline Project and replace exactly the nominated diagram's projection. The after Project must equal that entire expected value. Accepted names are Overview, Decomposition, Allocation, FunctionalDataflow, Capabilities and Traceability; both the baseline and target must be architecture/string projections, so typed StateMachine/Scenario changes have no allowance.
|
||||
|
||||
Source still requires either exact bytes or exactly one changed source file whose complete prefix and suffix outside the compiler-indexed projection value match byte-for-byte. Both versions must have an explicit projection value in the same file and the same reachable path set. The JSON report includes both byte spans and original/replacement field text, plus whether outside-field bytes were exact. Omitted/defaulted projection declarations fail this narrow source allowance rather than broadening it silently. Project mismatches report up to 200 unexpected JSON paths; full snapshots remain available to inspect all differences. Matching counts alone never establishes conservation.
|
||||
|
||||
This is a saved-source oracle. It cannot inspect unsaved desktop state, prove GUI click ordering, prove exact in-memory source history, or establish native selection usability. Save/capture each relevant checkpoint and associate screenshots/action logs with it. Model-changing Draw actions deliberately fail this unchanged/projection-only comparator; inspect their precise creation delta separately instead of adding a broad allowance.
|
||||
|
||||
## Native Draw fixture contract
|
||||
|
||||
`fixture NEW_DIR` constructs typed records through `Project::add_element`, `ensure_diagram`, `place` and `reidentify`. It creates OA Activity source/target and SA Function source/target, with distinct labels and two Traceability diagrams declared OA and SA. Both diagrams contain all four work cards, placed in two uncluttered rows: OA at (60,60)/(520,60), SA at (60,300)/(520,300). Initially there are no relations or ports, allowing native Draw to create the Interaction or FunctionalExchange and its ports using the application's actual workflow.
|
||||
|
||||
UUIDv5 identities and aliases are deterministic; no random identity remains from the construction helpers. The generated `oracle.json` records exact element/view IDs and the source entry. The generator validates the complete Project, compiles the core `format_bundle` output, checks four projected cards per view, saves the bundle using `source_files::new_project`/`expect_new_file`/`save_project`, recompiles the saved source and requires full Project equality. It then writes Project JSON. Generated output is synthetic and must never be described as recovered user source or an already-run native acceptance result.
|
||||
|
||||
Use the SA mixed view to inspect/select OA work before invoking contextual Draw; use the OA mixed view for the SA work counterpart. Semantic source and target must follow the selected work layer even when the canvas perspective differs. Preserve the documented scope distinction: opening the contextual form retains the canvas; successful Apply may perform the existing explicit post-review navigation. The separate architecture palette limitation is not claimed repaired.
|
||||
|
||||
## Reviewed source identities and remaining checks
|
||||
|
||||
SHA-256 at preparation:
|
||||
|
||||
- `src/main.rs`: `69c2c90f5cfebf86f755ea47861e4ab059e7359e442993a996fb9e91e2caeb7c`
|
||||
- `Cargo.toml`: `ba95c25a04dbfd0d6d19bd59f466e4082f840723ebE8180e5061a4a157e139c8`
|
||||
- Repository/seed Cargo.lock: `2aa80d113c5112b31c0767b874845d60f555e4e8ae7eb3ecd0b4b91954b55378`
|
||||
- Core `source_files.rs`: `bb903f8c53456f69c5ac213a192a1eb0d19abf8344bd82a03be8e659ba7bc94f`
|
||||
- Core `source_project.rs`: `d872f2a8f7c4add53f2708bf3fdaea1b4be715d4240f2d9b93baf4624d3f72a7`
|
||||
- CLI limits source `crates/oa-cli/src/lib.rs`: `790f3bdef78025813262c9677d4db5813f32978a398f3b7fb4900427dc0c314e`
|
||||
|
||||
Parent review risks: uncompiled Rust/API compatibility; the isolated lock's initial adjustment; no atomic filesystem snapshot; snapshot memory grows with lossless byte arrays; strict stable-source/explicit-value requirements can reject otherwise valid but insufficiently prepared input. Useful oracle checks before trusting native results: identical snapshots pass, one allowed projection change passes only with the matching nominated view/value, an unrelated comment change fails exact bytes despite equal Project, and unrelated semantic/placement edits fail even under the projection allowance. Those checks are proposed, not executed.
|
||||
|
||||
## Review correction before first execution
|
||||
|
||||
The initial source and README are preserved verbatim under `revisions/01-pre-warning-repair/`, with their hashes. The initial capture check incorrectly rejected all discovery diagnostics, including legitimate `unallocated_leaf` warnings; no successful run is attributed to that version. The correction filters the actual Error severity, keeps compiler warnings, binds stored diagnostic strings to fresh compilation, guards typed baseline projections as well as targets, and rejects duplicate source-path JSON keys. `project.json` is a convenience export; the comparator admits the embedded model and raw sources from `snapshot.json` only.
|
||||
|
||||
Two Rust tests now cover nonfatal engineering warnings/error rejection, deleted/invented warning rejection, and duplicate raw-file-key rejection. Parent command after the frozen gate: `cargo test --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir .local/navigation-oracle/target`. These tests and the utility still have not been compiled or run by this author. Runtime fixture generation and the actual warning-bearing source-load path remain required parent checks.
|
||||
|
||||
## Retained filesystem verification module (not executed)
|
||||
|
||||
`src/verification.rs` adds one test, `verification::filesystem_evaluator_contract`. Before any full utility test run, the parent must set `NAVIGATION_ORACLE_QA_DIR` to a fresh absolute path whose parent exists. The test intentionally fails without that explicit destination and refuses an existing destination. It never deletes or replaces evidence.
|
||||
|
||||
```powershell
|
||||
$env:NAVIGATION_ORACLE_QA_DIR = 'C:/ar-nav-01a0d717/.local/navigation-oracle-qa-run-01'
|
||||
cargo test --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir .local/navigation-oracle/target verification::filesystem_evaluator_contract -- --nocapture
|
||||
```
|
||||
|
||||
Use a different fresh path for every rerun, including after failure. Capture Cargo stdout/stderr and exit status. Under the supplied path, `checks.jsonl` records each completed control with its result and relevant errors; every source variant, snapshot, corrupt input and sentinel is retained. Comparison JSON reports also go to test stdout; `--nocapture` makes them available to the parent log. An interrupted or failed run retains partial evidence and has no final completion marker.
|
||||
|
||||
The test invokes actual `fixture`, `capture`, `snapshot`, `read_snapshot` and `compare` functions. Positive variants use `prepare_command` and `update_bundle_with_identity_map`, then the core new-project/save APIs; the core crate has no desktop Document type. The projection edit is a real EditDiagram command and the unrelated model edit a real Rename command. Corrupt snapshots are deliberately based on valid captures. Duplicate-key input is syntactically valid JSON with a duplicated existing path, and missing-include input omits an actual discovered dependency from a previously valid complete bundle.
|
||||
|
||||
Controls cover warning-bearing fixture generation/capture; identical snapshots; accepted projection-only change; wrong diagram/value; comment and CRLF changes with equal accepted Project; unrelated coherent semantic change; embedded-model and diagnostic tampering; duplicate/unsafe snapshot paths; invalid UTF-8 and missing include on disk; and preservation of an existing output sentinel for both snapshot and fixture commands. The comment/CRLF cases are checked both against the switched model in exact mode and against the original in projection-allowance mode. Both existing-output controls require a direct boxed std::io::Error with ErrorKind::AlreadyExists and record the actual kind/message; unrelated earlier errors cannot satisfy the refusal check. The previous module and README bytes are retained with hashes under revisions/04-before-output-refusal-check/.
|
||||
|
||||
This module adds no dependency or production behavior. Original reviewed main.rs `2cf2d1ced864ed339eb31470d42b370ba7d6de559c4679965fec4729984b08d6` and README are preserved under `revisions/03-before-filesystem-tests/`; the pre-attempt01 main.rs retained that entire source as an exact prefix and added only the two-line cfg(test) module declaration. The subsequent compiler repair is recorded below. Module SHA-256: `c7d6b184b7bfa579ec6dd2a777a0a417a65418f768d4b8cbf6afce7589ab9a25`. No Cargo, filesystem exercise or GUI run has occurred in this preparation task.
|
||||
|
||||
Bounded omissions: this exercise does not build a typed StateMachine/Scenario fixture to retest the architecture guard; it does not simulate concurrent filesystem races, interrupted multi-file writes, symlink/junction attacks or out-of-memory limits. It also cannot replace native Draw, selection or unsaved desktop Document-history checks.
|
||||
|
||||
## Attempt01 compiler repair (not rerun by this author)
|
||||
|
||||
Parent attempt01 exited 101 before any test executed: Rust E0382 at src/main.rs:261 identified the placement alias being moved by the assignment right-hand side before the left-hand side borrowed it to compute its ID. Evidence is ../navigation-oracle-runs/attempt01/tests.stderr. The isolated fixture generator now computes placement_id once before moving placement_alias and reuses that ID for reidentification and lookup. No alias clone, verification assertion, production repository file or dependency changed.
|
||||
|
||||
The exact pre-repair main.rs and README are preserved with SHA-256 records under revisions/05-before-placement-id-repair/. Main was da515eff05bd34eb0cb354e412db74dbdede72518c99839fc5d75d56cdd7c892; README was 82355e35120e8f0508d146306d37efa8c22eac8d3f0c59029d5cdaff28835257. The parent-adjusted isolated Cargo.lock is e97d03c226c2c3f2328b68bcb6d823df00c0569918fa9891d50504cd12e0af1d and was left untouched. This repair has not been compiled or run by its author; parent owns a rerun with fresh logs and QA root. Earlier no-execution statements describe preparation and remain no test-pass claims.
|
||||
## Tested standalone Windows runtime and parent execution evidence
|
||||
|
||||
This is a receipt review, not a new execution. Parent attempt02 at repository HEAD `c7065db76ecca3faa733ccb5f09b87fd0a0958be` built with Rust 1.98.1 gnullvm / LLVM-MinGW 20260826 UCRT and passed all three tests. Its authoritative input manifest, commands and result are `../navigation-oracle-runs/attempt02/result.json` (SHA-256 `1ed50fe9c42ff4a67084ed70ce934404f5549ca68bc54e6d2f1436e66ea8d22f`); logs and retained QA controls are alongside it and in `../navigation-oracle-qa-attempt02/`. This actual build attribution does not change the source-review `reviewed_core` pin of `6520a89dc042dd433454f6cc63cb23b0e6df7911`.
|
||||
|
||||
The first standalone fixture receipt, `../navigation-oracle-commands/draw-fixture/receipt.json`, records exit 3221225781 (`0xC0000135`, Windows DLL-not-found status), with no stdout or stderr. The same executable succeeded after staging beside it the matching `libunwind.dll`: `../navigation-oracle-commands/draw-fixture-runtime02/receipt.json` records exit 0, unchanged executable, empty stderr, and child PATH `C:\Windows\System32;C:\Windows`. Its stdout reports the saved `../native-draw-fixture/project.arcadia` and Project JSON. The intermediate `draw-fixture-runtime/failure.json` instead records a Python recorder `SystemRoot` key-case failure before the oracle started; it is not an oracle or fixture result.
|
||||
|
||||
Verified current private runtime files in `../navigation-oracle-runtime/`:
|
||||
|
||||
| File | Bytes | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| navigation-oracle.exe | 114890752 | `bd2db2e177d9d21f44b45e5efa89071e00155d31509672bf5aea6a57915e09a6` |
|
||||
| libunwind.dll | 190976 | `f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4` |
|
||||
|
||||
The executable came from `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/target-gnullvm/debug/navigation-oracle.exe`. A verified identical DLL is `C:/ar-save-01a0d717/.local/package46/extracted/ArcadiaStudio-0.2.0-preview.save-diagnostics.20261001-x86_64-pc-windows-gnullvm/libunwind.dll`. For another private runtime copy, use a new directory and verify both hashes before launch. This recipe reproduces the tested arrangement; the particular new paths below have not been executed:
|
||||
|
||||
```powershell
|
||||
$runtimeCopy = 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime-copy-01'
|
||||
$fixtureOutput = 'C:/ar-nav-01a0d717/.local/native-draw-fixture-copy-01'
|
||||
if ((Test-Path -LiteralPath $runtimeCopy) -or (Test-Path -LiteralPath $fixtureOutput)) { throw 'Choose fresh runtime/output paths' }
|
||||
New-Item -ItemType Directory -Path $runtimeCopy -ErrorAction Stop | Out-Null
|
||||
Copy-Item -LiteralPath 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime/navigation-oracle.exe' -Destination $runtimeCopy -ErrorAction Stop
|
||||
Copy-Item -LiteralPath 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime/libunwind.dll' -Destination $runtimeCopy -ErrorAction Stop
|
||||
$oracle = Join-Path $runtimeCopy 'navigation-oracle.exe'
|
||||
if ((Get-FileHash -LiteralPath $oracle -Algorithm SHA256).Hash -ne 'bd2db2e177d9d21f44b45e5efa89071e00155d31509672bf5aea6a57915e09a6') { throw 'Executable pin mismatch' }
|
||||
if ((Get-FileHash -LiteralPath (Join-Path $runtimeCopy 'libunwind.dll') -Algorithm SHA256).Hash -ne 'f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4') { throw 'Runtime pin mismatch' }
|
||||
$savedOraclePath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
||||
& $oracle fixture $fixtureOutput
|
||||
if ($LASTEXITCODE -ne 0) { throw "Oracle exited $LASTEXITCODE" }
|
||||
} finally {
|
||||
$env:PATH = $savedOraclePath
|
||||
}
|
||||
```
|
||||
|
||||
The PATH change applies only to this shell and is restored; no compiler directory is needed for this standalone launch. For recorded commands, the existing `../record-native-oracle.py` (SHA-256 `abbe6fae3055673ca3c33f0f87cba21189dd2ca905f63acc2da69b0b605fbc23`) checks executable/DLL pins, sets the child system-only PATH, and retains argv, stdout, stderr, exit, repository HEAD and build-receipt hash in a fresh command directory. The Python recorder is optional evidence tooling, not a runtime dependency of the Rust executable.
|
||||
|
||||
## Fixed01 saved-evidence inventory for scoped review
|
||||
|
||||
At this review, `../navigation-oracle-commands/fixed01-*` contains ten snapshot receipts and seven compare receipts. All 17 record exit 0, empty stderr and an unchanged executable with the runtime pin above. Snapshots are `prepared`, `baseline`, `inspected`, `name-applied`, `name-undo`, `implicit-applied`, `implicit-undo`, `switched`, `projection-undo` and `projection-redo`, under `../navigation-native/fixed01/`. Each compare folder below contains its actual `receipt.json`, `stdout` JSON report and `stderr`:
|
||||
|
||||
| Compare receipt suffix after fixed01- | Before → after | Reported contract/result |
|
||||
| --- | --- | --- |
|
||||
| preparation-equality | prepared → baseline | Exact full Project and raw source, pass |
|
||||
| inspection-equality | baseline → inspected | Exact full Project and raw source, pass |
|
||||
| name-undo-equality | baseline → name-undo | Exact full Project and raw source, pass |
|
||||
| implicit-undo-equality | baseline → implicit-undo | Exact full Project and raw source, pass |
|
||||
| projection-conservation | baseline → switched | Only nominated projection change, pass |
|
||||
| projection-undo-equality | baseline → projection-undo | Exact full Project and raw source, pass |
|
||||
| projection-redo-equality | switched → projection-redo | Exact full Project and raw source, pass |
|
||||
|
||||
The projection report nominates diagram `00000000-0038-4000-8000-000000000001`; only `views/diagram_issue38_representative_mixed.arcadia` changes, from `Traceability` at byte span [338,350) to `Allocation` at [338,348). Full expected Project and entry/path set match; prefix/suffix outside the projection value are byte-exact. Its raw_source_bytes_equal is correctly false because that one permitted token changes. Six exact comparisons instead report no changed files and raw_source_bytes_equal true. These receipts preserve the existing `work_without_behavior_context` warning; success does not mean a warning-free model.
|
||||
|
||||
The separate parameterized `../check-native-name.py` (SHA-256 `617a189b546ded2c9133dd0884152cea265d2f86041a5ef983394e6063c82f72`) reviews full saved Project equality after exactly one nominated relation-name edit, and full raw source equality after exactly one unique declaration-header replacement. It fixes baseline/fixture paths and relation UUID `628cb4fc-fe63-5f6b-b364-6268f26b5740`; only the after-directory, expected name and new report stem are parameters. It is not a general parser/admission substitute: the pinned Rust snapshot receipts supply source admission. Intended invocation for the already-recorded implicit case was `python .local/check-native-name.py implicit-applied "Implicit assessment delivery" implicit-delta-check`; it now refuses overwriting the existing report. Use a fresh report stem for any separately authorized rerun.
|
||||
|
||||
`../navigation-native/fixed01/implicit-delta-check.json` is SHA-256 `eb14e21c5b75dd3ba63200ccf8860cb85aee6f5688b4db2626958060178f5f09`, reports all five checks true, and binds that evaluator hash, baseline snapshot `7695c2ff4aa735110cb198f6451be596c5ac59a65ae9ccd7c7602e0259cb5e9a`, after snapshot `86fe91f1eaf0e8a0a9a509c7f4d2e5f90ec5f2b01a9169e0678dc8342a7a282b`, and original fixture `9db1015369f450d5810c96a01fddb8a009d57f6aafbda5182703f15ded23d9c8`. The changed declaration is in `oa/behavior/process_oa_assessment_procedure.arcadia`. The earlier explicit-name `name-delta-check.json` also reports five true checks but has no evaluator_sha256 field; do not retrospectively attribute it to the current parameterized helper.
|
||||
|
||||
This inventory does not establish GUI click order, one-step interaction history, visible content, fresh reopening, or unsaved RAM conservation. Those claims require the parent's corresponding native capture/action evidence. No GUI, Rust/Python oracle command or Cargo test was executed during this README update. Pre-update README SHA-256 `496bc4eab252671232f6d2269b95fa0bc3596aad36341ca362cca9c140df3e08` is retained under `revisions/06-before-runtime-instructions/`; source and evaluator bytes are unchanged.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "27-enabled-functional-tool-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "81c25b3d2e1a189389b5abdb0b3987d5984fb3491a56600ff36c870b584efab7"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\fixed01\\bin\\arcadia-studio.exe",
|
||||
"id": 15008282,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough * — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "20-click-away-undo-saved-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "6b57d48bfdf74e3b942cb57fadf5e3ade360a6838ae5f7178e4a30de0815ac83"
|
||||
}
|
||||
]
|
||||
}
|
||||