Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd8e6c1bda | ||
|
|
2ca84676ee | ||
|
|
336720559b | ||
|
|
636093aae6 | ||
|
|
c0908f101a | ||
|
|
24b1eab07d | ||
|
|
47f55199b4 | ||
|
|
41a23fbbe7 | ||
|
|
1a372f1b68 | ||
|
|
bb6e8a44ac | ||
|
|
1a669130fb | ||
|
|
715036e132 | ||
|
|
a9cd364eac | ||
|
|
8ddfadf440 | ||
|
|
897d9f0494 | ||
|
|
4deced53b9 | ||
|
|
9e99618cc3 | ||
|
|
a6b9f6029a | ||
|
|
a8c959fed4 | ||
|
|
359bea3949 | ||
|
|
17eaa03fd6 | ||
|
|
6ae75fb2cc | ||
|
|
542b10ac58 | ||
|
|
62d3ffe9cb | ||
|
|
c1b78acb9a | ||
|
|
3e916f623c | ||
|
|
e9167f5cc8 | ||
|
|
1006b496ae | ||
|
|
a2a326f183 | ||
|
|
e34ba27e26 | ||
|
|
301126f609 | ||
|
|
f82e29d6b8 | ||
|
|
c4897485b7 | ||
|
|
015273ac8a | ||
|
|
5e04800be1 | ||
|
|
cf37c36be1 | ||
|
|
f3dd8356bf | ||
|
|
846cae81e7 | ||
|
|
2a0579daaf | ||
|
|
ffa7b16003 | ||
|
|
a9b9c81eab | ||
|
|
0b61771d07 | ||
|
|
6f4dfc67b3 | ||
|
|
5960563b23 | ||
|
|
df4256cb2b | ||
|
|
1d56386708 | ||
|
|
c87a0c2bd9 | ||
|
|
cc3d6a4875 | ||
|
|
6a6cc83bc8 | ||
|
|
9f88938632 | ||
|
|
17f1f89fef | ||
|
|
4fbdf6f385 | ||
|
|
bfc259854f | ||
|
|
4ffff0d9d9 | ||
|
|
836f9e14e7 | ||
|
|
031c4dbdbb | ||
|
|
b278866e8c | ||
|
|
3733a657bb | ||
|
|
84434d55a0 | ||
|
|
f95d031048 | ||
|
|
8abc144cf2 | ||
|
|
9e51cd72ba | ||
|
|
3c22fe2629 | ||
|
|
cccd66c5d5 | ||
|
|
355f6d4d24 |
+2
-2
@@ -17,14 +17,14 @@ Apply these criteria to the scope requested by the user:
|
||||
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
|
||||
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
|
||||
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
|
||||
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- A fresh independent review covers requirements, domain / semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
|
||||
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build / run / deployment / verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer, projection: P
|
||||
description: "Native group fixture; preserve unrelated records exactly.".into(),
|
||||
layer,
|
||||
projection,
|
||||
layout: None,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
|
||||
@@ -41,6 +41,7 @@ fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
|
||||
(p, diagram, exchanges, categories, contexts)
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
|
||||
@@ -81,6 +81,7 @@ fn diagram(
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod scenario_layout;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
mod source_defaults;
|
||||
@@ -21,6 +22,9 @@ pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use scenario_layout::{
|
||||
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
|
||||
@@ -1436,6 +1436,7 @@ pub enum ProjectionKind {
|
||||
FunctionalDataflow,
|
||||
Capabilities,
|
||||
StateMachine { machine: Id },
|
||||
Scenario { scenario: Id },
|
||||
}
|
||||
impl ProjectionKind {
|
||||
pub fn state_machine(self) -> Option<Id> {
|
||||
@@ -1445,8 +1446,30 @@ impl ProjectionKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The semantic owner of a saved Scenario representation.
|
||||
pub fn scenario(self) -> Option<Id> {
|
||||
match self {
|
||||
Self::Scenario { scenario } => Some(scenario),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly classify architecture views; absence of a state machine is insufficient.
|
||||
pub fn is_architecture(self) -> bool {
|
||||
match self {
|
||||
Self::Overview
|
||||
| Self::Decomposition
|
||||
| Self::Allocation
|
||||
| Self::Traceability
|
||||
| Self::FunctionalDataflow
|
||||
| Self::Capabilities => true,
|
||||
Self::StateMachine { .. } | Self::Scenario { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(self, kind: Kind) -> bool {
|
||||
match self {
|
||||
Self::Scenario { .. } => false,
|
||||
Self::StateMachine { .. } => {
|
||||
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
|
||||
}
|
||||
@@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::StateMachine { .. } => "State machine",
|
||||
Self::Scenario { .. } => "Scenario",
|
||||
Self::Allocation => "Architecture · allocated work",
|
||||
Self::FunctionalDataflow => "Functional dataflow",
|
||||
Self::Capabilities => "Missions & capabilities",
|
||||
@@ -1495,6 +1519,8 @@ pub struct Diagram {
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<crate::scenario_layout::DiagramLayout>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
@@ -1955,6 +1981,7 @@ impl Project {
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -278,6 +278,11 @@ fn project_diagram_impl(
|
||||
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
||||
index: Option<&crate::view_options::ViewIndex>,
|
||||
) -> ProjectedView {
|
||||
// Saved Scenario layout is not an architecture/state projection. Its dedicated
|
||||
// renderer is not wired yet; never interpret its payload through this fallback.
|
||||
if d.projection.scenario().is_some() {
|
||||
return ProjectedView::default();
|
||||
}
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
@@ -394,6 +399,7 @@ fn project_diagram_impl(
|
||||
continue;
|
||||
}
|
||||
let included = match d.projection {
|
||||
ProjectionKind::Scenario { .. } => false,
|
||||
ProjectionKind::StateMachine { machine } => matches!(&r.body,
|
||||
RelationBody::StateTransition { transition_kind, .. }
|
||||
if *transition_kind != TransitionKind::Internal
|
||||
|
||||
@@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
/// Every semantic/presentation reference in a diagram, excluding its own identities.
|
||||
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
|
||||
let mut copy = d.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_diagram_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
|
||||
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
|
||||
d.view_options.visit_references(&mut visit);
|
||||
match &mut d.projection {
|
||||
ProjectionKind::StateMachine { machine } => visit(machine),
|
||||
ProjectionKind::Scenario { scenario } => visit(scenario),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(layout) = &mut d.layout {
|
||||
layout.visit_references(&mut visit);
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
visit(&mut route.edge);
|
||||
}
|
||||
for node in d.nodes.values_mut() {
|
||||
for anchor in &mut node.port_anchors {
|
||||
visit(&mut anchor.port);
|
||||
}
|
||||
visit(&mut node.element);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight reference-key collisions before the caller changes object identities.
|
||||
/// Fresh targets are allowed. This is not whole-project admission: unrelated
|
||||
/// invalid records remain repairable within a transaction. Future typed reference
|
||||
/// domains must compose their own preflight here before any remap mutation.
|
||||
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
let remap = |id: Id| if id == old { new } else { id };
|
||||
for diagram in p.diagrams.values() {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
continue;
|
||||
};
|
||||
let mut lanes = std::collections::BTreeMap::new();
|
||||
for lane in &layout.lanes {
|
||||
let affected = lane.lifeline == old;
|
||||
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario lane position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut messages = std::collections::BTreeMap::new();
|
||||
for message in &layout.messages {
|
||||
let mut address = message.address.clone();
|
||||
let affected = address.occurrence == old || address.references.contains(&old);
|
||||
address.occurrence = remap(address.occurrence);
|
||||
for reference in &mut address.references {
|
||||
*reference = remap(*reference);
|
||||
}
|
||||
if let Some(previous_affected) = messages.insert(address, affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario message position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
check_remap(p, old, new)?;
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.visit_references(|id| {
|
||||
visit_diagram_refs(d, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
if let ProjectionKind::StateMachine { machine } = &mut d.projection
|
||||
&& *machine == old
|
||||
{
|
||||
*machine = new;
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
@@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
for d in p.diagrams.values() {
|
||||
if d.projection
|
||||
.state_machine()
|
||||
.is_some_and(|machine| removed.contains(&machine))
|
||||
.or_else(|| d.projection.scenario())
|
||||
.is_some_and(|owner| removed.contains(&owner))
|
||||
{
|
||||
removed.insert(d.id);
|
||||
}
|
||||
@@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
crate::scenario_layout::prune_deleted(d, &removed);
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "references/scenario_layout_tests.rs"]
|
||||
mod scenario_layout_tests;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&view).unwrap().layout
|
||||
else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn admitted(project: &Project) {
|
||||
let errors: Vec<_> = crate::validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == crate::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
|
||||
.unwrap();
|
||||
let lanes = ["Sender", "Receiver"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
let messages = ["First message", "Second message"].map(|name| {
|
||||
let id = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
id
|
||||
});
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.to_vec();
|
||||
let views = ["delivery_view", "review_view"].map(|alias| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 120.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 420.,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[0]),
|
||||
send_y: 220.,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[1]),
|
||||
send_y: 360.,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
|
||||
let mut f = fixture();
|
||||
let before = f.project.clone();
|
||||
let temporary = Id::new_v4();
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
admitted(&f.project);
|
||||
assert_eq!(
|
||||
f.project.elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.messages[1], f.messages[0]]
|
||||
);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(
|
||||
rows.lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[1],
|
||||
x: 120.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[0],
|
||||
x: 420.
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 220.
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[0]),
|
||||
send_y: 360.
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
// Reverse the swaps: no values or identities other than those requested drift.
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
|
||||
for message in [false, true] {
|
||||
let mut f = fixture();
|
||||
let [old, new] = if message { f.messages } else { f.lanes };
|
||||
let before = f.project.clone();
|
||||
assert!(check_remap(&f.project, old, new).is_err());
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
|
||||
let mut f = fixture();
|
||||
let fresh = Id::new_v4();
|
||||
// A repair candidate can already have a dangling coordinate key. It must not
|
||||
// silently merge with the live lane, even though the destination ID is vacant.
|
||||
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
|
||||
let before = f.project.clone();
|
||||
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
|
||||
let mut f = fixture();
|
||||
let [old, new] = [Id::new_v4(), Id::new_v4()];
|
||||
let rows = layout_mut(&mut f.project, f.views[0]);
|
||||
// Future/invalid expanded candidates still participate in reference safety.
|
||||
rows.messages[0].address.references = vec![old];
|
||||
rows.messages[1].address = OccurrenceEndpoint {
|
||||
references: vec![new],
|
||||
occurrence: f.messages[0],
|
||||
};
|
||||
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
|
||||
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
|
||||
let before = f.project.clone();
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
let fresh = Id::new_v4();
|
||||
remap_references(&mut f.project, old, fresh).unwrap();
|
||||
let rows = layout(&f.project, f.views[0]);
|
||||
assert_eq!(rows.messages[0].address.references, vec![fresh]);
|
||||
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
|
||||
assert_eq!(rows.messages[1].address.references, vec![new]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
|
||||
let mut f = fixture();
|
||||
let other_owner = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
|
||||
.unwrap();
|
||||
let mut other_view = f.project.diagrams[&f.views[0]].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "independent_view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
other_view.layout = None;
|
||||
f.project.diagrams.insert(other_view.id, other_view.clone());
|
||||
admitted(&f.project);
|
||||
let fresh_owner = Id::new_v4();
|
||||
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
|
||||
.unwrap()
|
||||
.project;
|
||||
for view in f.views {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&view].projection.scenario(),
|
||||
Some(fresh_owner)
|
||||
);
|
||||
}
|
||||
let impact = deletion_impact(&f.project, fresh_owner);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.chain(&f.lanes)
|
||||
.chain(&f.messages)
|
||||
.all(|id| impact.contains(id))
|
||||
);
|
||||
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
|
||||
delete_cascade(&mut f.project, fresh_owner);
|
||||
assert_eq!(f.project.diagrams[&other_view.id], other_view);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.all(|id| !f.project.diagrams.contains_key(id))
|
||||
);
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
|
||||
let mut f = fixture();
|
||||
let lane = f.project.elements[&f.lanes[0]].clone();
|
||||
let message = f.project.elements[&f.messages[0]].clone();
|
||||
delete_cascade(&mut f.project, lane.id);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
|
||||
// Missing endpoint assignment does not erase a still-live message's spacing.
|
||||
assert_eq!(rows.messages.len(), 2);
|
||||
}
|
||||
let mut pending = BTreeSet::new();
|
||||
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
|
||||
// Pruning happens during deletion, before later batch commands can recreate IDs.
|
||||
assert!(
|
||||
layout(&f.project, f.views[0])
|
||||
.messages
|
||||
.iter()
|
||||
.all(|row| row.address.occurrence != message.id)
|
||||
);
|
||||
f.project.elements.insert(lane.id, lane);
|
||||
f.project.elements.insert(message.id, message);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.insert(0, f.messages[0]);
|
||||
finish_deletions(&mut f.project, &pending);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
}
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
|
||||
let mut f = fixture();
|
||||
let unrelated = Id::new_v4();
|
||||
let rows = layout_mut(&mut f.project, f.views[1]);
|
||||
let invalid = ScenarioLanePosition {
|
||||
lifeline: unrelated,
|
||||
x: MAX_COORDINATE * 2.,
|
||||
};
|
||||
rows.lanes.extend([invalid.clone(), invalid]);
|
||||
let fresh = Id::new_v4();
|
||||
let untouched = f.project.diagrams[&f.views[1]].clone();
|
||||
let before = f.project.clone();
|
||||
remap_references(&mut f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(f.project, before);
|
||||
// The duplicate lane records do not involve the remapped message identity.
|
||||
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).lanes,
|
||||
layout_from(&untouched).lanes
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).messages[0]
|
||||
.address
|
||||
.occurrence,
|
||||
fresh
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
|
||||
}
|
||||
|
||||
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("missing layout")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
|
||||
let mut f = fixture();
|
||||
// Expanded addresses are not admitted in this slice, but deleting their
|
||||
// explicit references must still make an intermediate repair candidate safe.
|
||||
layout_mut(&mut f.project, f.views[0]).messages[0]
|
||||
.address
|
||||
.references = vec![f.lanes[0]];
|
||||
delete_cascade(&mut f.project, f.lanes[0]);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[0]).messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
|
||||
assert!(f.project.elements.contains_key(&f.messages[0]));
|
||||
admitted(&f.project);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Authored Scenario presentation. Structural admission never changes event order.
|
||||
use crate::{
|
||||
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
|
||||
Project, SharedBody, validation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DiagramLayout {
|
||||
Scenario(ScenarioLayout),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLayout {
|
||||
#[serde(default)]
|
||||
pub lanes: Vec<ScenarioLanePosition>,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<ScenarioMessagePosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLanePosition {
|
||||
pub lifeline: Id,
|
||||
pub x: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioMessagePosition {
|
||||
pub address: OccurrenceEndpoint,
|
||||
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
|
||||
pub send_y: f32,
|
||||
}
|
||||
|
||||
impl DiagramLayout {
|
||||
/// Visit every explicit reference, including unsupported paths in a candidate.
|
||||
/// The caller owns remap preflight and final atomic validation.
|
||||
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
|
||||
match self {
|
||||
Self::Scenario(layout) => {
|
||||
for lane in &mut layout.lanes {
|
||||
visit(&mut lane.lifeline);
|
||||
}
|
||||
for message in &mut layout.messages {
|
||||
for reference in &mut message.address.references {
|
||||
visit(reference);
|
||||
}
|
||||
visit(&mut message.address.occurrence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
|
||||
/// One payload plus one record per lane/message. No graph objects are introduced.
|
||||
pub fn record_count(diagram: &Diagram) -> usize {
|
||||
match &diagram.layout {
|
||||
None => 0,
|
||||
Some(DiagramLayout::Scenario(layout)) => 1usize
|
||||
.saturating_add(layout.lanes.len())
|
||||
.saturating_add(layout.messages.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn coordinate(value: f32) -> bool {
|
||||
value.is_finite() && value.abs() <= MAX_COORDINATE
|
||||
}
|
||||
|
||||
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
|
||||
let scenario = diagram
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
|
||||
if !p
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
|
||||
));
|
||||
}
|
||||
Ok(scenario)
|
||||
}
|
||||
|
||||
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
|
||||
p.elements.get(&lifeline).is_some_and(|element| {
|
||||
element.kind() == Kind::Lifeline
|
||||
&& element.parent == Some(owner)
|
||||
&& element.layer() == layer
|
||||
})
|
||||
}
|
||||
|
||||
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
|
||||
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
|
||||
/// cycles terminate, and malformed parent/step links do not manufacture membership.
|
||||
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
|
||||
let mut messages = BTreeSet::new();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut pending = vec![owner];
|
||||
while let Some(scope) = pending.pop() {
|
||||
if !visited.insert(scope) {
|
||||
continue;
|
||||
}
|
||||
let Some(element) = p.elements.get(&scope) else {
|
||||
continue;
|
||||
};
|
||||
for id in element.steps().into_iter().flatten() {
|
||||
let Some(item) = p.elements.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if item.parent != Some(scope) || item.layer() != layer {
|
||||
continue;
|
||||
}
|
||||
match item.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
}) => {
|
||||
messages.insert(*id);
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
for branch in branches {
|
||||
if p.elements.get(branch).is_some_and(|operand| {
|
||||
operand.parent == Some(*id)
|
||||
&& operand.layer() == layer
|
||||
&& matches!(
|
||||
operand.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
pending.push(*branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
|
||||
if diagram.projection.scenario().is_none() {
|
||||
if diagram.layout.is_some() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_projection",
|
||||
"layout",
|
||||
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let scenario = match owner(p, diagram) {
|
||||
Ok(owner) => owner,
|
||||
Err(error) => {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_context",
|
||||
"projection",
|
||||
error.0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_content",
|
||||
"layout",
|
||||
"Scenario views do not accept architecture node placements or edge routes.",
|
||||
);
|
||||
}
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
return;
|
||||
};
|
||||
let mut lanes = BTreeSet::new();
|
||||
for lane in &layout.lanes {
|
||||
if !lanes.insert(lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_lane",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per lifeline.",
|
||||
);
|
||||
}
|
||||
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_lifeline",
|
||||
"layout",
|
||||
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
|
||||
);
|
||||
}
|
||||
if !coordinate(lane.x) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let members = direct_messages(p, scenario, diagram.layer);
|
||||
let mut addresses = BTreeSet::new();
|
||||
for message in &layout.messages {
|
||||
// Reject expanded paths without cloning or indexing their potentially large contents.
|
||||
if !message.address.references.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_address",
|
||||
"layout",
|
||||
"Expanded-reference message positions are not supported; references must be empty.",
|
||||
);
|
||||
} else if !addresses.insert(message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_message",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per message address.",
|
||||
);
|
||||
}
|
||||
if !members.contains(&message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_message",
|
||||
"layout",
|
||||
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
|
||||
);
|
||||
}
|
||||
if !coordinate(message.send_y) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
|
||||
let scenario = owner(p, diagram)?;
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Scenario views do not accept architecture node placements or edge routes.".into(),
|
||||
));
|
||||
}
|
||||
Ok((diagram, scenario))
|
||||
}
|
||||
|
||||
fn check_message(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
owner: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<(), ModelError> {
|
||||
if !address.references.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Expanded-reference message positions are not supported; references must be empty."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
|
||||
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
|
||||
let DiagramLayout::Scenario(layout) = diagram
|
||||
.layout
|
||||
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
layout
|
||||
}
|
||||
|
||||
/// Target-scoped preparation, not whole-project or whole-layout validation.
|
||||
/// Unrelated intermediate errors remain repairable by later commands in a batch.
|
||||
pub fn set_lane(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
|
||||
return Err(ModelError(
|
||||
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.lanes
|
||||
.iter()
|
||||
.filter(|row| row.lifeline == position.lifeline)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate lane position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.lanes
|
||||
.iter_mut()
|
||||
.find(|row| row.lifeline == position.lifeline)
|
||||
{
|
||||
if existing.x != position.x {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.lanes.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, lifeline) {
|
||||
return Err(ModelError(
|
||||
"Removing a lane position requires an owned Lifeline.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.lanes.retain(|row| row.lifeline != lifeline);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, &position.address)?;
|
||||
if !coordinate(position.send_y) {
|
||||
return Err(ModelError(
|
||||
"Message position requires a finite bounded send_y coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|row| row.address == position.address)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate message position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|row| row.address == position.address)
|
||||
{
|
||||
if existing.send_y != position.send_y {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.messages.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, address)?;
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.messages.retain(|row| &row.address != address);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prune only identities in the caller's actual deletion closure, never visibility
|
||||
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
|
||||
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
|
||||
layout
|
||||
.lanes
|
||||
.retain(|lane| !deleted.contains(&lane.lifeline));
|
||||
layout.messages.retain(|message| {
|
||||
!deleted.contains(&message.address.occurrence)
|
||||
&& !message
|
||||
.address
|
||||
.references
|
||||
.iter()
|
||||
.any(|id| deleted.contains(id))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,802 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
|
||||
ScenarioViewpoint,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
performer: Id,
|
||||
lane: Id,
|
||||
repeated_lane: Id,
|
||||
first: Id,
|
||||
second: Id,
|
||||
nested: Id,
|
||||
fragment: Id,
|
||||
branch: Id,
|
||||
view: Id,
|
||||
other_view: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn set_body(element: &mut Element, body: OccurrenceBody) {
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
fn diagram(owner: Id, alias: &str) -> Diagram {
|
||||
Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn has_code(project: &Project, code: &str) -> bool {
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == code)
|
||||
}
|
||||
fn layout(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("expected saved payload")
|
||||
};
|
||||
layout
|
||||
}
|
||||
fn save(project: &mut Project, replacement: Diagram) {
|
||||
project.diagrams.insert(replacement.id, replacement);
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
project.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let receiver = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
|
||||
.unwrap();
|
||||
let lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
|
||||
.unwrap();
|
||||
let repeated_lane = project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Controller review lane",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let receiver_lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
|
||||
.unwrap();
|
||||
for (id, represented) in [
|
||||
(lane, performer),
|
||||
(repeated_lane, performer),
|
||||
(receiver_lane, receiver),
|
||||
] {
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let exchange = Id::new_v4();
|
||||
project.relations.insert(
|
||||
exchange,
|
||||
Relation {
|
||||
id: exchange,
|
||||
alias: "delivery_channel".into(),
|
||||
name: "Delivery".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
|
||||
},
|
||||
);
|
||||
let first = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
|
||||
.unwrap();
|
||||
for (id, sender) in [(first, lane), (second, repeated_lane)] {
|
||||
set_body(
|
||||
project.elements.get_mut(&id).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver_lane),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let fragment = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Optional delivery",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
|
||||
.unwrap();
|
||||
let nested = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unfinished nested delivery",
|
||||
Some(branch),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&nested).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&branch).unwrap(),
|
||||
OccurrenceBody::Branch {
|
||||
guard: "requested".into(),
|
||||
occurrences: vec![nested],
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![branch],
|
||||
},
|
||||
);
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![first, second, fragment];
|
||||
let view_record = diagram(owner, "nominal_view");
|
||||
let other_record = diagram(owner, "review_view");
|
||||
let view = view_record.id;
|
||||
let other_view = other_record.id;
|
||||
save(&mut project, view_record);
|
||||
save(&mut project, other_record);
|
||||
assert_admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
performer,
|
||||
lane,
|
||||
repeated_lane,
|
||||
first,
|
||||
second,
|
||||
nested,
|
||||
fragment,
|
||||
branch,
|
||||
view,
|
||||
other_view,
|
||||
exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
|
||||
let mut f = fixture();
|
||||
let semantics = (f.project.elements.clone(), f.project.relations.clone());
|
||||
for (view, line, x) in [
|
||||
(f.view, f.lane, 420.),
|
||||
(f.view, f.repeated_lane, 680.),
|
||||
(f.other_view, f.lane, 900.),
|
||||
] {
|
||||
let result =
|
||||
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
for (view, id, y) in [
|
||||
(f.view, f.first, 200.),
|
||||
(f.view, f.second, 350.),
|
||||
(f.other_view, f.first, 260.),
|
||||
] {
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: y,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view]).lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.repeated_lane,
|
||||
x: 680.
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view])
|
||||
.messages
|
||||
.iter()
|
||||
.map(|r| (r.address.occurrence, r.send_y))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(f.first, 200.), (f.second, 350.)]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
|
||||
260.
|
||||
);
|
||||
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
|
||||
assert_eq!(
|
||||
(f.project.elements.clone(), f.project.relations.clone()),
|
||||
semantics
|
||||
);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.nested),
|
||||
send_y: -80.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
let before = f.project.clone();
|
||||
let expanded = OccurrenceEndpoint {
|
||||
references: vec![f.fragment],
|
||||
occurrence: f.nested,
|
||||
};
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: expanded.clone(),
|
||||
send_y: 400.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(remove_message(&f.project, f.view, &expanded).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
|
||||
assert!(has_code(&f.project, "scenario_layout_address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
|
||||
let mut f = fixture();
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.performer,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
let foreign_lane = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
|
||||
.unwrap();
|
||||
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.first);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positions_reject_even_when_values_match() {
|
||||
let mut f = fixture();
|
||||
let lane = ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.,
|
||||
};
|
||||
let message = ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
};
|
||||
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
entries.lanes = vec![lane.clone(), lane.clone()];
|
||||
entries.messages = vec![message.clone(), message.clone()];
|
||||
assert!(has_code(&f.project, "duplicate_scenario_lane"));
|
||||
assert!(has_code(&f.project, "duplicate_scenario_message"));
|
||||
assert!(set_lane(&f.project, f.view, lane).is_err());
|
||||
assert!(set_message(&f.project, f.view, message).is_err());
|
||||
// Explicit removal can repair all duplicates of the targeted identity.
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
|
||||
let mut f = fixture();
|
||||
let absent = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
|
||||
assert_eq!(
|
||||
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
|
||||
absent
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_value(&absent)
|
||||
.unwrap()
|
||||
.get("layout")
|
||||
.is_none()
|
||||
);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layout(&result).messages[0].send_y.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&result).unwrap(),
|
||||
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
|
||||
);
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
assert_eq!(
|
||||
result.layout,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
|
||||
);
|
||||
assert_ne!(result, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
|
||||
let mut f = fixture();
|
||||
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
let before = serde_json::to_string(&f.project).unwrap();
|
||||
for value in [
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
MAX_COORDINATE + 1.,
|
||||
] {
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
|
||||
let mut f = fixture();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.push(ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: f32::INFINITY,
|
||||
});
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 300.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
|
||||
let mut f = fixture();
|
||||
let scenario = f.project.diagrams[&f.view].projection;
|
||||
assert_eq!(scenario.scenario(), Some(f.owner));
|
||||
assert!(!scenario.is_architecture());
|
||||
assert!(!scenario.includes(Kind::Lifeline));
|
||||
assert!(ProjectionKind::Overview.is_architecture());
|
||||
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
|
||||
assert!(has_code(&f.project, "scenario_diagram_context"));
|
||||
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(crate::EdgeRoute {
|
||||
edge: f.exchange,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
assert!(has_code(&f.project, "scenario_diagram_content"));
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(has_code(&f.project, "scenario_layout_projection"));
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
|
||||
f.project.diagrams[&f.view]
|
||||
);
|
||||
assert_eq!(
|
||||
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
|
||||
serde_json::json!(f.first)
|
||||
);
|
||||
let mut unknown = encoded.clone();
|
||||
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
|
||||
let mut missing = encoded;
|
||||
missing["layout"]["Scenario"]["messages"][0]["address"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("references");
|
||||
assert!(serde_json::from_value::<Diagram>(missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
|
||||
let mut f = fixture();
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let mut copied = f.project.diagrams[&f.view].clone();
|
||||
copied.id = f.other_view;
|
||||
copied.alias = "review_view".into();
|
||||
copied.name = "Review view".into();
|
||||
save(&mut f.project, copied.clone());
|
||||
assert_admitted(&f.project);
|
||||
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
|
||||
let mut candidate = f.project.diagrams[&f.view].clone();
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
|
||||
assert!(layout(&candidate).lanes.is_empty());
|
||||
assert_eq!(layout(&candidate).messages.len(), 1);
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
|
||||
assert!(layout(&candidate).messages.is_empty());
|
||||
assert!(candidate.layout.is_some());
|
||||
assert_eq!(f.project.diagrams[&f.other_view], copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_visitor_and_record_count_cover_explicit_rows() {
|
||||
let f = fixture();
|
||||
let mut d = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(record_count(&d), 0);
|
||||
let rows = payload(&mut d);
|
||||
rows.lanes.push(ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 50.,
|
||||
});
|
||||
rows.messages.push(ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
});
|
||||
assert_eq!(record_count(&d), 3);
|
||||
let replacement = Id::new_v4();
|
||||
d.layout.as_mut().unwrap().visit_references(|id| {
|
||||
if *id == f.first {
|
||||
*id = replacement;
|
||||
}
|
||||
});
|
||||
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
|
||||
assert_eq!(layout(&d).messages[0].send_y, 100.);
|
||||
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
|
||||
let mut f = fixture();
|
||||
let called = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
f.project.elements.get_mut(&called).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Called Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let called_message = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&called_message).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&called)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![called_message];
|
||||
let usage = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&usage).unwrap(),
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(called),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(usage);
|
||||
assert_admitted(&f.project);
|
||||
for target in [
|
||||
address(called_message),
|
||||
address(usage),
|
||||
OccurrenceEndpoint {
|
||||
references: vec![usage],
|
||||
occurrence: called_message,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: target,
|
||||
send_y: 200.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod source_lifecycle;
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Source/command lifecycle checks for the candidate payload. Public version
|
||||
//! assignment remains the coordinated codec integration gate.
|
||||
use super::*;
|
||||
use crate::{Command, Session, language, prepare_command, source_project::*};
|
||||
|
||||
fn authored(f: &Fixture) -> CompiledSourceProject {
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &initial.index.declarations[&f.view];
|
||||
let source = bundle.files.get_mut(&location.path).unwrap();
|
||||
let text = format!(
|
||||
r#"diagram nominal_view "Nominal review" id "{}" {{
|
||||
projection {{ Scenario {{ scenario @{}; }}; }};
|
||||
layout {{ Scenario {{
|
||||
lanes [
|
||||
// Controller spacing rationale.
|
||||
{{ lifeline @ // first lane identity
|
||||
{}; x 4.20e2; }},
|
||||
// Repeated performer has its own lane.
|
||||
{{ lifeline @{}; x 680; }}
|
||||
];
|
||||
messages [
|
||||
// First delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
|
||||
// Second delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
|
||||
];
|
||||
}}; }};
|
||||
}}
|
||||
"#,
|
||||
f.view,
|
||||
f.project.elements[&f.owner].alias,
|
||||
f.project.elements[&f.lane].alias,
|
||||
f.project.elements[&f.repeated_lane].alias,
|
||||
f.project.elements[&f.first].alias,
|
||||
f.project.elements[&f.second].alias
|
||||
);
|
||||
source.replace_range(location.span.start..location.span.end, &text);
|
||||
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut changed = original.decoded.project.clone();
|
||||
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
assert_eq!(&updated.index.declarations[&f.view].path, path);
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(text.contains("send_y 390"), "{text}");
|
||||
for (other, bytes) in &original.bundle.files {
|
||||
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
|
||||
assert_eq!(&updated.bundle.files[other], bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let temporary = Id::new_v4();
|
||||
let command = Command::Batch(vec![
|
||||
Command::Reidentify(f.first, temporary),
|
||||
Command::Reidentify(f.second, f.first),
|
||||
Command::Reidentify(temporary, f.second),
|
||||
]);
|
||||
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
|
||||
let mut changed = prepared.project;
|
||||
payload(changed.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.swap(0, 1);
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&changed,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
let first_comment = text.find("// First delivery").unwrap();
|
||||
assert!(text[first_comment..].contains(&format!(
|
||||
"occurrence @{}",
|
||||
changed.elements[&f.second].alias
|
||||
)));
|
||||
assert!(text[first_comment..].contains("send_y 2.00e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut session = Session::new(original.decoded.project.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::SetScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 510.,
|
||||
},
|
||||
},
|
||||
Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
},
|
||||
]))
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
assert_eq!(session.revision(), 1);
|
||||
assert_eq!(changed.elements, original.decoded.project.elements);
|
||||
assert_eq!(changed.relations, original.decoded.project.relations);
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original.decoded.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), 3);
|
||||
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(noop.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
|
||||
let f = fixture();
|
||||
let before = language::format(&f.project);
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::RemoveScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
lifeline: f.lane,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(language::format(session.project()), before);
|
||||
let mut present = f.project.clone();
|
||||
present.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let text = language::format(&present);
|
||||
assert_ne!(text, before);
|
||||
assert_eq!(language::parse(&text).unwrap(), present);
|
||||
assert_eq!(language::parse(&before).unwrap(), f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let before = original.bundle.clone();
|
||||
let path = original.index.declarations[&f.view].path.clone();
|
||||
for replacement in ["x 1e30", "x not_a_number"] {
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace("x 4.20e2", replacement);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace(
|
||||
&format!("occurrence @{}", f.project.elements[&f.first].alias),
|
||||
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
|
||||
);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
|
||||
let f = fixture();
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &compiled.index.declarations[&f.view];
|
||||
let path = location.path.clone();
|
||||
bundle.files.get_mut(&path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n layout // explicitly automatic\n none;\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut described = original.decoded.project.clone();
|
||||
described.diagrams.get_mut(&f.view).unwrap().description =
|
||||
"Review spacing independently".into();
|
||||
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
|
||||
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
|
||||
assert_eq!(updated.decoded.project, described);
|
||||
let mut empty = described.clone();
|
||||
empty.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(present.decoded.project, empty);
|
||||
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
|
||||
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, described);
|
||||
assert!(!cleared.bundle.files[&path].contains("layout"));
|
||||
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() {
|
||||
let f = fixture();
|
||||
let initial = authored(&f);
|
||||
let mut bundle = initial.bundle.clone();
|
||||
let location = &initial.index.declarations[&f.performer];
|
||||
bundle.files.get_mut(&location.path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n description // explicitly default prose\n \"\";\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut expected = original.decoded.project.clone();
|
||||
let performer = expected.elements.get_mut(&f.performer).unwrap();
|
||||
performer.name = "Reviewed controller".into();
|
||||
performer.alias = "reviewed_controller".into();
|
||||
expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into();
|
||||
let rows = payload(expected.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, expected);
|
||||
let element = &updated.index.declarations[&f.performer];
|
||||
let text = &updated.bundle.files[&element.path][element.span.start..element.span.end];
|
||||
assert!(
|
||||
text.contains("description // explicitly default prose\n \"\";"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("categories"),
|
||||
"an omitted default must stay omitted: {text}"
|
||||
);
|
||||
let diagram = &updated.index.declarations[&f.view];
|
||||
let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap()
|
||||
);
|
||||
assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}");
|
||||
assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap());
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(!text.contains("view_options"), "{text}");
|
||||
expected.diagrams.get_mut(&f.view).unwrap().layout = None;
|
||||
let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, expected);
|
||||
let declaration = &cleared.index.declarations[&f.view];
|
||||
let text =
|
||||
&cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end];
|
||||
assert!(!text.contains("layout"), "{text}");
|
||||
assert!(text.contains("// Controller spacing rationale."), "{text}");
|
||||
assert_eq!(
|
||||
update_bundle(&cleared, &expected, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
cleared.bundle,
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,22 @@ pub enum Command {
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
EditDiagram(Id, Diagram),
|
||||
SetScenarioLanePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
},
|
||||
RemoveScenarioLanePosition {
|
||||
diagram: Id,
|
||||
lifeline: Id,
|
||||
},
|
||||
SetScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
},
|
||||
RemoveScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
address: OccurrenceEndpoint,
|
||||
},
|
||||
CreateArtifact(ArtifactLink),
|
||||
EditArtifact(Id, ArtifactLink),
|
||||
CreateTrace(Trace),
|
||||
@@ -488,6 +504,26 @@ fn apply_command(
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetScenarioLanePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
|
||||
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::SetScenarioMessagePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioMessagePosition { diagram, address } => {
|
||||
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.artifact_links.insert(value.id, value.clone());
|
||||
@@ -878,6 +914,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::references::check_remap(p, old, new)?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
@@ -904,12 +941,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new);
|
||||
crate::references::remap_references(p, old, new)?;
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ModelError(
|
||||
"Scenario layout requires the Scenario geometry operation.".into(),
|
||||
));
|
||||
}
|
||||
let parents = crate::projection::parents(p, d);
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for (id, parent) in &parents {
|
||||
|
||||
@@ -52,7 +52,13 @@ pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
|
||||
_ => &["source", "target"],
|
||||
}
|
||||
}
|
||||
Context::Diagram => &[],
|
||||
Context::Diagram => {
|
||||
// Admit the optional payload without restoring a synthetic null in
|
||||
// CompleteForEdit: absence there distinguishes a real layout clear
|
||||
// from an unrelated edit beside an authored explicit `layout none`.
|
||||
fields.insert("layout".into(), Value::Null);
|
||||
&[]
|
||||
}
|
||||
Context::Node => &["element"],
|
||||
Context::Artifact => &["owner"],
|
||||
Context::Trace => &["oa", "sa"],
|
||||
|
||||
@@ -972,7 +972,18 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
|
||||
let context = if old.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&previous.fields),
|
||||
&next.fields,
|
||||
context,
|
||||
local,
|
||||
);
|
||||
}
|
||||
}
|
||||
if old.id != project.id || old.alias != project.alias || old.name != project.name {
|
||||
@@ -1224,6 +1235,29 @@ impl<'a> InsertionSyntax<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// Schema context is explicit: similarly named fields in arbitrary records must
|
||||
// never acquire Scenario row identity or move their authored trivia.
|
||||
#[derive(Clone, Copy)]
|
||||
enum EditContext {
|
||||
Other,
|
||||
Diagram,
|
||||
Layout,
|
||||
Scenario,
|
||||
Lanes,
|
||||
Messages,
|
||||
}
|
||||
impl EditContext {
|
||||
fn field(self, name: &str) -> Self {
|
||||
match (self, name) {
|
||||
(Self::Diagram, "layout") => Self::Layout,
|
||||
(Self::Layout, "Scenario") => Self::Scenario,
|
||||
(Self::Scenario, "lanes") => Self::Lanes,
|
||||
(Self::Scenario, "messages") => Self::Messages,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
@@ -1301,6 +1335,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
@@ -1316,7 +1351,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.record(actual.span, fields, previous, next, edits);
|
||||
self.record(actual.span, fields, previous, next, context, edits);
|
||||
}
|
||||
(EditKind::List(items), EditKind::List(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
@@ -1326,7 +1361,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.list(actual.span, items, previous, next, edits);
|
||||
self.list(actual.span, items, previous, next, context, edits);
|
||||
}
|
||||
(EditKind::Reference(_), EditKind::Reference(alias)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
@@ -1379,6 +1414,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &BTreeMap<String, language::EditField>,
|
||||
before: Option<&BTreeMap<String, language::EditField>>,
|
||||
after: &BTreeMap<String, language::EditField>,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
// A typed constructor tag change retains shared fields and their trivia.
|
||||
@@ -1402,7 +1438,13 @@ impl SyntaxUpdate<'_> {
|
||||
},
|
||||
new_key.clone(),
|
||||
));
|
||||
self.value(&field.value, Some(&old.value), &new.value, edits);
|
||||
self.value(
|
||||
&field.value,
|
||||
Some(&old.value),
|
||||
&new.value,
|
||||
context.field(new_key),
|
||||
edits,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1414,9 +1456,16 @@ impl SyntaxUpdate<'_> {
|
||||
.and_then(|fields| fields.get(key))
|
||||
.map(|field| &field.value),
|
||||
&next.value,
|
||||
context.field(key),
|
||||
edits,
|
||||
);
|
||||
} else {
|
||||
} else if !(matches!(context, EditContext::Diagram)
|
||||
&& key == "layout"
|
||||
&& before.is_some_and(|fields| !fields.contains_key(key)))
|
||||
{
|
||||
// Canonical None omits layout. Preserve an authored explicit
|
||||
// none while another field changes; a real Some->None clear
|
||||
// still removes the former payload and retains its trivia.
|
||||
erase_syntax(self.source, field.span, edits);
|
||||
}
|
||||
}
|
||||
@@ -1470,6 +1519,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &[language::EditValue],
|
||||
before: Option<&[language::EditValue]>,
|
||||
after: &[language::EditValue],
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
@@ -1482,6 +1532,7 @@ impl SyntaxUpdate<'_> {
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1494,6 +1545,7 @@ impl SyntaxUpdate<'_> {
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1522,7 +1574,7 @@ impl SyntaxUpdate<'_> {
|
||||
.all(|(i, source)| *source == Some(i))
|
||||
{
|
||||
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
|
||||
self.value(current, previous.get(i), next, edits);
|
||||
self.value(current, previous.get(i), next, EditContext::Other, edits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1555,7 +1607,13 @@ impl SyntaxUpdate<'_> {
|
||||
let chunk = chunks[index];
|
||||
text.push_str(&self.source[chunk.start..current.span.start]);
|
||||
let mut local = Vec::new();
|
||||
self.value(current, previous.get(index), next, &mut local);
|
||||
self.value(
|
||||
current,
|
||||
previous.get(index),
|
||||
next,
|
||||
EditContext::Other,
|
||||
&mut local,
|
||||
);
|
||||
text.push_str(&changed_text(self.source, current.span, local));
|
||||
suffix = &self.source[current.span.end..chunk.end];
|
||||
} else {
|
||||
@@ -1594,14 +1652,58 @@ impl SyntaxUpdate<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn scenario_item_key(
|
||||
value: &language::EditValue,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
let EditKind::Record(fields) = &value.kind else {
|
||||
return None;
|
||||
};
|
||||
let reference = |value: &language::EditValue| {
|
||||
if let EditKind::Reference(alias) = &value.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match context {
|
||||
EditContext::Lanes => {
|
||||
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
|
||||
}
|
||||
EditContext::Messages => {
|
||||
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
|
||||
return None;
|
||||
};
|
||||
let occurrence = reference(&address.get("occurrence")?.value)?;
|
||||
let path: Vec<_> = match address.get("references") {
|
||||
None => Vec::new(),
|
||||
Some(field) => {
|
||||
let EditKind::List(path) = &field.value.kind else {
|
||||
return None;
|
||||
};
|
||||
path.iter().map(reference).collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
};
|
||||
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(context, EditContext::Lanes | EditContext::Messages) {
|
||||
return scenario_item_key(value, aliases, context);
|
||||
}
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
|
||||
@@ -72,6 +72,11 @@ pub(super) fn upgrade(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
if project.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
},
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -750,7 +750,7 @@ pub fn plan_transition(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if !compatible_existing(project, *source, target, request.direction) {
|
||||
if !counterpart_compatible(project, *source, target, request.direction) {
|
||||
conflict(
|
||||
&mut plan,
|
||||
Some(*source),
|
||||
@@ -1317,12 +1317,22 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
|
||||
}
|
||||
inferred_kind(source, direction) == Some(target)
|
||||
}
|
||||
fn compatible_existing(
|
||||
/// Whether existing records have compatible layers, categories and kinds for
|
||||
/// an ordinary transition. This is candidate eligibility, not approval of a
|
||||
/// complete plan: properties, dependencies and structural conflicts still need
|
||||
/// the normal preview and apply validation. Manual traces are not restricted
|
||||
/// by this predicate.
|
||||
pub fn counterpart_compatible(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
target: Id,
|
||||
direction: TransitionDirection,
|
||||
) -> bool {
|
||||
if p.layer_of(source) != Some(direction.source_layer())
|
||||
|| p.layer_of(target) != Some(direction.target_layer())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
match (p.elements.get(&source), p.elements.get(&target)) {
|
||||
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
|
||||
_ => match (p.relations.get(&source), p.relations.get(&target)) {
|
||||
|
||||
@@ -619,6 +619,7 @@ 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 {
|
||||
|
||||
@@ -231,6 +231,7 @@ fn node_project() -> Project {
|
||||
},
|
||||
)]),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
expected
|
||||
|
||||
@@ -41,6 +41,7 @@ fn fixture() -> Fixture {
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
|
||||
assert_eq!(session.project(), &after);
|
||||
let fresh = Id::new_v4();
|
||||
let mut reidentified = after.clone();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh);
|
||||
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
|
||||
let mut e = reidentified.elements.remove(&f.r1).unwrap();
|
||||
e.id = fresh;
|
||||
reidentified.elements.insert(fresh, e);
|
||||
|
||||
@@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! Existing counterpart eligibility is a layer/category/kind decision, not
|
||||
//! validation or acceptance of an entire transition plan. The catalog fixtures
|
||||
//! below intentionally contain partial records (unowned states, etc.). The
|
||||
//! final regression separately starts from a structurally valid real project.
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let body = if kind == Kind::Port {
|
||||
// Legacy ports can exist in preserved OA models. This does not offer
|
||||
// new typed SA implementation ports for creation in portless OA.
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: None,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
ElementBody::new(kind, layer).unwrap()
|
||||
};
|
||||
p.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("candidate_{}", id.simple()),
|
||||
name: "Identical candidate label".into(),
|
||||
description: String::new(),
|
||||
parent: None,
|
||||
body,
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
|
||||
// Contract: language.rst §5 trace families and the explicit capability /
|
||||
// mission alternative. State constructors remain eligible for explicit
|
||||
// review across the state family; selecting a candidate does not accept a
|
||||
// constructor change or make invalid ownership legal.
|
||||
let mut allowed: BTreeSet<(Kind, Kind)> = [
|
||||
(Kind::Activity, Kind::Function),
|
||||
(Kind::Entity, Kind::System),
|
||||
(Kind::Entity, Kind::SystemActor),
|
||||
(Kind::Actor, Kind::System),
|
||||
(Kind::Actor, Kind::SystemActor),
|
||||
(Kind::Capability, Kind::SystemCapability),
|
||||
(Kind::OperationalMission, Kind::Mission),
|
||||
(Kind::Capability, Kind::Mission),
|
||||
(Kind::Process, Kind::FunctionalChain),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
for kind in [
|
||||
Kind::ExchangeItem,
|
||||
Kind::ExchangeCategory,
|
||||
Kind::DataType,
|
||||
Kind::DataField,
|
||||
Kind::Interface,
|
||||
Kind::Port,
|
||||
Kind::Scenario,
|
||||
Kind::Lifeline,
|
||||
Kind::Occurrence,
|
||||
Kind::StateMachine,
|
||||
Kind::Region,
|
||||
Kind::Event,
|
||||
Kind::Constraint,
|
||||
] {
|
||||
allowed.insert((kind, kind));
|
||||
}
|
||||
let vertices = [
|
||||
Kind::State,
|
||||
Kind::Mode,
|
||||
Kind::Initial,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::Junction,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
];
|
||||
for oa in vertices {
|
||||
for sa in vertices {
|
||||
allowed.insert((oa, sa));
|
||||
}
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
let oa: BTreeMap<_, _> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| kind.supports(Layer::Oa))
|
||||
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
|
||||
.collect();
|
||||
let sa: BTreeMap<_, _> = Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| kind.supports(Layer::Sa))
|
||||
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
|
||||
.collect();
|
||||
let original = p.clone();
|
||||
for (&oa_kind, &oa_id) in &oa {
|
||||
for (&sa_kind, &sa_id) in &sa {
|
||||
let expected = allowed.contains(&(oa_kind, sa_kind));
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
|
||||
expected,
|
||||
"OA {oa_kind:?} → SA {sa_kind:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
|
||||
expected,
|
||||
"SA {sa_kind:?} → OA {oa_kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
p, original,
|
||||
"Eligibility must not establish correspondence or edit records"
|
||||
);
|
||||
}
|
||||
|
||||
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
|
||||
let id = Id::new_v4();
|
||||
// Only the semantic layer and relation category participate in candidate
|
||||
// eligibility. Endpoint compatibility belongs to full plan validation.
|
||||
p.relations.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: format!("relation_{}", id.simple()),
|
||||
name: "Identical relation label".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(kind, anchor, anchor),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
|
||||
// Work and boundary exchanges are different families. The boundary /
|
||||
// boundary pair permits preserved legacy OA boundary records; ordinary
|
||||
// portless OA communication means map to SA boundary exchanges.
|
||||
let mut allowed = vec![
|
||||
(RelationKind::Interaction, RelationKind::FunctionalExchange),
|
||||
(
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange,
|
||||
),
|
||||
(
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::BoundaryExchange,
|
||||
),
|
||||
];
|
||||
let shared = [
|
||||
RelationKind::Allocation,
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
RelationKind::DataAssociation,
|
||||
RelationKind::ExchangeAllocation,
|
||||
RelationKind::CapabilityInvolvement,
|
||||
RelationKind::CapabilityInclude,
|
||||
RelationKind::CapabilityExtend,
|
||||
RelationKind::CapabilityGeneralization,
|
||||
RelationKind::MissionInvolvement,
|
||||
RelationKind::MissionCapability,
|
||||
RelationKind::SequenceLink,
|
||||
RelationKind::ContextualLink,
|
||||
RelationKind::ChainInvolvement,
|
||||
RelationKind::InterfaceProvision,
|
||||
RelationKind::InterfaceRequirement,
|
||||
RelationKind::StateTransition,
|
||||
RelationKind::Availability,
|
||||
RelationKind::StateAction,
|
||||
];
|
||||
for kind in shared {
|
||||
allowed.push((kind, kind));
|
||||
}
|
||||
let mut p = Project::empty();
|
||||
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
|
||||
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
|
||||
let oa: Vec<_> = shared
|
||||
.into_iter()
|
||||
.chain([
|
||||
RelationKind::Interaction,
|
||||
RelationKind::CommunicationMean,
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::RoleRealization,
|
||||
RelationKind::RoleAssignment,
|
||||
])
|
||||
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
|
||||
.collect();
|
||||
let sa: Vec<_> = shared
|
||||
.into_iter()
|
||||
.chain([
|
||||
RelationKind::FunctionalExchange,
|
||||
RelationKind::BoundaryExchange,
|
||||
RelationKind::PortAllocation,
|
||||
])
|
||||
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
|
||||
.collect();
|
||||
for &(oa_kind, oa_id) in &oa {
|
||||
for &(sa_kind, sa_id) in &sa {
|
||||
let expected = allowed.contains(&(oa_kind, sa_kind));
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
|
||||
expected,
|
||||
"OA {oa_kind:?} → SA {sa_kind:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
|
||||
expected,
|
||||
"SA {sa_kind:?} → OA {oa_kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
|
||||
let mut p = Project::empty();
|
||||
let oa = element(&mut p, Layer::Oa, Kind::Activity);
|
||||
let sa = element(&mut p, Layer::Sa, Kind::Function);
|
||||
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
|
||||
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
|
||||
for (direction, source, target, source_relation, target_relation) in [
|
||||
(
|
||||
TransitionDirection::OaToSa,
|
||||
oa,
|
||||
sa,
|
||||
oa_exchange,
|
||||
sa_exchange,
|
||||
),
|
||||
(
|
||||
TransitionDirection::SaToOa,
|
||||
sa,
|
||||
oa,
|
||||
sa_exchange,
|
||||
oa_exchange,
|
||||
),
|
||||
] {
|
||||
assert!(counterpart_compatible(&p, source, target, direction));
|
||||
assert!(counterpart_compatible(
|
||||
&p,
|
||||
source_relation,
|
||||
target_relation,
|
||||
direction
|
||||
));
|
||||
for (a, b) in [
|
||||
(target, source),
|
||||
(source, source),
|
||||
(target, target),
|
||||
(target_relation, source_relation),
|
||||
(source_relation, source_relation),
|
||||
(source, target_relation),
|
||||
(source_relation, target),
|
||||
] {
|
||||
assert!(!counterpart_compatible(&p, a, b, direction));
|
||||
}
|
||||
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
absent_or_nonsemantic,
|
||||
direction
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
absent_or_nonsemantic,
|
||||
target,
|
||||
direction
|
||||
));
|
||||
}
|
||||
}
|
||||
p.elements.remove(&sa);
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
oa,
|
||||
sa,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
sa,
|
||||
oa,
|
||||
TransitionDirection::SaToOa
|
||||
));
|
||||
// The retained relation header cannot borrow a layer from a deleted owner.
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
oa_exchange,
|
||||
sa_exchange,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
|
||||
let p = Project::demo();
|
||||
let original = p.clone();
|
||||
assert!(validation::validate(&p).is_empty());
|
||||
let source = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Activity)
|
||||
.unwrap()
|
||||
.id;
|
||||
let port = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
|
||||
.unwrap()
|
||||
.id;
|
||||
let function = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::Function)
|
||||
.unwrap()
|
||||
.id;
|
||||
assert!(counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
function,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
assert!(!counterpart_compatible(
|
||||
&p,
|
||||
source,
|
||||
port,
|
||||
TransitionDirection::OaToSa
|
||||
));
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(port),
|
||||
target_kind: None,
|
||||
});
|
||||
let plan = plan_transition(&p, &request).unwrap();
|
||||
assert!(
|
||||
plan.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(source)
|
||||
&& conflict.target == Some(port)
|
||||
&& conflict.field.is_none())
|
||||
);
|
||||
assert!(apply_transition(&p, &plan).is_err());
|
||||
assert_eq!(p, original);
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
|
||||
//! OA is portless; the SA half contains typed ports. Deletion expectations below
|
||||
//! are fixture-owned identity lists, never results of the deletion implementation.
|
||||
use oa_core::{
|
||||
language, persistence,
|
||||
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
||||
transitions::*,
|
||||
validation::{self, Severity},
|
||||
*,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Side {
|
||||
root: Id,
|
||||
elements: Vec<Id>,
|
||||
exchange: Id,
|
||||
diagram: Id,
|
||||
nodes: Vec<Id>,
|
||||
artifacts: Vec<Id>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
source: Side,
|
||||
target: Side,
|
||||
trace: Id,
|
||||
trace_artifact: Id,
|
||||
}
|
||||
|
||||
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: p.fresh_alias(label, "evidence", id),
|
||||
name: label.into(),
|
||||
description: "Independently authored rationale".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "git:engineering/design.md".into(),
|
||||
revision: Some("review-17".into()),
|
||||
fragment: Some("retained-decision".into()),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
|
||||
fn side(p: &mut Project, layer: Layer) -> Side {
|
||||
let (kind, prefix, relation) = match layer {
|
||||
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
|
||||
Layer::Sa => (
|
||||
Kind::Function,
|
||||
"Technical",
|
||||
RelationKind::FunctionalExchange,
|
||||
),
|
||||
};
|
||||
let root = p
|
||||
.add_element(kind, layer, &format!("{prefix} design"), None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&root).unwrap().description =
|
||||
format!("{prefix} independently reviewed intent");
|
||||
let send = p
|
||||
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
|
||||
.unwrap();
|
||||
let receive = p
|
||||
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
|
||||
.unwrap();
|
||||
p.elements.get_mut(&send).unwrap().description =
|
||||
"Preserve the detailed sending responsibility".into();
|
||||
p.elements.get_mut(&receive).unwrap().description =
|
||||
"Preserve the detailed receiving responsibility".into();
|
||||
// A shared, separately owned definition survives deletion of its users.
|
||||
let item = p
|
||||
.add_element(
|
||||
Kind::ExchangeItem,
|
||||
layer,
|
||||
&format!("{prefix} information"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
p.add_element(
|
||||
Kind::DataField,
|
||||
layer,
|
||||
&format!("{prefix} payload"),
|
||||
Some(item),
|
||||
)
|
||||
.unwrap();
|
||||
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
|
||||
let mut elements = vec![root, send, receive];
|
||||
match &mut p.relations.get_mut(&exchange).unwrap().body {
|
||||
RelationBody::Interaction { items, .. } => *items = vec![item],
|
||||
RelationBody::FunctionalExchange {
|
||||
items,
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} => {
|
||||
*items = vec![item];
|
||||
let (output, input) = (*source_port, *target_port);
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::FunctionOutput {
|
||||
outgoing_items: vec![item],
|
||||
},
|
||||
legacy: None,
|
||||
};
|
||||
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::FunctionInput {
|
||||
incoming_items: vec![item],
|
||||
},
|
||||
legacy: None,
|
||||
};
|
||||
elements.extend([output, input]);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
p.relations.get_mut(&exchange).unwrap().description =
|
||||
"Retain direction, item contract and relation identity".into();
|
||||
let diagram = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
|
||||
let nodes = [root, send, receive]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, element)| {
|
||||
p.place(
|
||||
diagram,
|
||||
element,
|
||||
Position {
|
||||
x: 43.25 + index as f32 * 271.,
|
||||
y: 97.5 + index as f32 * 83.,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let root_node = p
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nodes[0])
|
||||
.unwrap();
|
||||
root_node.size = Size {
|
||||
width: 611.,
|
||||
height: 319.,
|
||||
};
|
||||
root_node.collapsed = true;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: exchange,
|
||||
// Both directions must preserve authored endpoint preferences,
|
||||
// including dormant preferences on the SA semantic-port edge.
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![Position {
|
||||
x: 337.5,
|
||||
y: 423.25,
|
||||
}],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.7,
|
||||
offset: Position { x: 11., y: -17. },
|
||||
}),
|
||||
});
|
||||
let artifacts = [root, send, exchange, nodes[0]]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
|
||||
.collect();
|
||||
artifact(p, item, &format!("{prefix} shared item evidence"));
|
||||
Side {
|
||||
root,
|
||||
elements,
|
||||
exchange,
|
||||
diagram,
|
||||
nodes,
|
||||
artifacts,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepted(p: &Project) {
|
||||
let issues = validation::validate(p);
|
||||
// This fixture deliberately isolates decomposition and correspondence from
|
||||
// performer allocation and process/chain/scenario inclusion. Each surviving
|
||||
// leaf (including a newly transitioned root) must have exactly those two
|
||||
// incomplete-work warnings; each surviving local exchange lacks a behavior
|
||||
// context. Check exact record IDs and fields, including duplicate warnings.
|
||||
// The system of interest and item contracts are complete fixture facts.
|
||||
assert!(
|
||||
issues
|
||||
.iter()
|
||||
.all(|issue| issue.severity == Severity::Warning),
|
||||
"{issues:?}"
|
||||
);
|
||||
let mut expected = Vec::new();
|
||||
for element in p.elements.values().filter(|element| {
|
||||
matches!(element.kind(), Kind::Activity | Kind::Function)
|
||||
&& !p
|
||||
.elements
|
||||
.values()
|
||||
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
|
||||
}) {
|
||||
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
|
||||
expected.push(("work_without_behavior_context", Some(element.id), None));
|
||||
}
|
||||
for relation in p.relations.values().filter(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange
|
||||
)
|
||||
}) {
|
||||
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
|
||||
}
|
||||
let mut actual = issues
|
||||
.iter()
|
||||
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
|
||||
.collect::<Vec<_>>();
|
||||
expected.sort();
|
||||
actual.sort();
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"Unexpected fixture diagnostics: {issues:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn round_trip(p: &Project) -> String {
|
||||
accepted(p);
|
||||
let json = serde_json::to_vec_pretty(p).unwrap();
|
||||
assert_eq!(persistence::decode(&json).unwrap(), *p);
|
||||
let source = language::format(p);
|
||||
let compiled = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(compiled.decoded.project, *p);
|
||||
assert_eq!(language::format(&compiled.decoded.project), source);
|
||||
source
|
||||
}
|
||||
|
||||
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
|
||||
let mut request = TransitionRequest::new(direction, vec![source]);
|
||||
request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
fn fixture(direction: TransitionDirection) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
project.name = "Endpoint lifecycle conservation".into();
|
||||
project
|
||||
.add_element(Kind::System, Layer::Sa, "System of interest", None)
|
||||
.unwrap();
|
||||
let oa = side(&mut project, Layer::Oa);
|
||||
let sa = side(&mut project, Layer::Sa);
|
||||
let (source, target) = match direction {
|
||||
TransitionDirection::OaToSa => (oa, sa),
|
||||
TransitionDirection::SaToOa => (sa, oa),
|
||||
};
|
||||
accepted(&project);
|
||||
let before = project.clone();
|
||||
let mut request = explicit(direction, source.root, target.root);
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
request.resolutions.push(FieldResolution {
|
||||
source: source.root,
|
||||
field,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
});
|
||||
}
|
||||
let plan = plan_transition(&project, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
project = apply_transition(&project, &plan).unwrap();
|
||||
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
|
||||
let mut without_trace = project.clone();
|
||||
without_trace.traces.remove(&trace);
|
||||
assert_eq!(
|
||||
without_trace, before,
|
||||
"Initial review must only add correspondence"
|
||||
);
|
||||
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
|
||||
round_trip(&project);
|
||||
Fixture {
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
trace,
|
||||
trace_artifact,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_only_new_trace(
|
||||
before: &Project,
|
||||
after: &Project,
|
||||
direction: TransitionDirection,
|
||||
source: Id,
|
||||
target: Id,
|
||||
) -> Id {
|
||||
let added = after
|
||||
.traces
|
||||
.values()
|
||||
.filter(|trace| !before.traces.contains_key(&trace.id))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(added.len(), 1);
|
||||
assert_eq!(after.traces.len(), before.traces.len() + 1);
|
||||
let trace = added[0];
|
||||
let (oa, sa) = match direction {
|
||||
TransitionDirection::OaToSa => (source, target),
|
||||
TransitionDirection::SaToOa => (target, source),
|
||||
};
|
||||
assert_eq!(
|
||||
(trace.oa, trace.sa, trace.kind),
|
||||
(oa, sa, TraceKind::Refinement)
|
||||
);
|
||||
assert_eq!(
|
||||
trace.baseline,
|
||||
Some(TraceBaseline {
|
||||
oa: after.properties(oa).unwrap(),
|
||||
sa: after.properties(sa).unwrap(),
|
||||
semantic: None,
|
||||
})
|
||||
);
|
||||
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
|
||||
trace.id
|
||||
}
|
||||
|
||||
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
|
||||
let mut expected = fixture.project.clone();
|
||||
for id in &side.elements {
|
||||
assert!(expected.elements.remove(id).is_some());
|
||||
}
|
||||
assert!(expected.relations.remove(&side.exchange).is_some());
|
||||
assert!(expected.traces.remove(&fixture.trace).is_some());
|
||||
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
|
||||
assert!(expected.artifact_links.remove(id).is_some());
|
||||
}
|
||||
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
|
||||
for id in &side.nodes {
|
||||
assert!(diagram.nodes.remove(id).is_some());
|
||||
}
|
||||
// This fixture authored exactly one route, for its removed local exchange.
|
||||
assert_eq!(diagram.routes.len(), 1);
|
||||
assert_eq!(diagram.routes[0].edge, side.exchange);
|
||||
diagram.routes.clear();
|
||||
expected
|
||||
}
|
||||
|
||||
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
plan.changes[0].source,
|
||||
plan.changes[0].target,
|
||||
plan.changes[0].action
|
||||
),
|
||||
(source, target, TransitionAction::Reuse)
|
||||
);
|
||||
assert!(plan.changes[0].fields.is_empty());
|
||||
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
|
||||
let mut session = Session::new(p.clone());
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), p);
|
||||
assert_eq!(session.revision(), revision);
|
||||
assert!(!session.can_undo());
|
||||
}
|
||||
|
||||
fn source_deletion(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let original_source = round_trip(&fixture.project);
|
||||
let expected = expected_deletion(&fixture, &fixture.source);
|
||||
let mut session = Session::new(fixture.project.clone());
|
||||
session
|
||||
.execute(Command::Delete(fixture.source.root))
|
||||
.unwrap();
|
||||
// Whole-project equality covers every retained target child, typed SA port,
|
||||
// exchange, placement, route, artifact, shared item and unchanged identity.
|
||||
assert_eq!(session.project(), &expected);
|
||||
let deleted_source = round_trip(session.project());
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &fixture.project);
|
||||
assert_eq!(round_trip(session.project()), original_source);
|
||||
assert!(!session.can_undo(), "Deletion is one history transaction");
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_eq!(round_trip(session.project()), deleted_source);
|
||||
}
|
||||
|
||||
fn implicit_creation(
|
||||
p: &Project,
|
||||
direction: TransitionDirection,
|
||||
source: Id,
|
||||
forbidden_target: Id,
|
||||
) -> (Project, Id) {
|
||||
let request = TransitionRequest::new(direction, vec![source]);
|
||||
let plan = plan_transition(p, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
let change = &plan.changes[0];
|
||||
assert_eq!(change.source, source);
|
||||
assert_eq!(change.action, TransitionAction::Create);
|
||||
let target = change.target;
|
||||
assert_ne!(target, forbidden_target);
|
||||
assert!(!p.contains(target));
|
||||
let after = apply_transition(p, &plan).unwrap();
|
||||
let trace = assert_only_new_trace(p, &after, direction, source, target);
|
||||
let created = &after.elements[&target];
|
||||
assert_eq!(created.id, target, "Apply preserves the preview identity");
|
||||
assert_eq!(created.name, p.elements[&source].name);
|
||||
assert_eq!(created.description, p.elements[&source].description);
|
||||
assert_eq!(created.parent, None);
|
||||
assert_eq!(
|
||||
created.body,
|
||||
match direction {
|
||||
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
|
||||
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
|
||||
}
|
||||
);
|
||||
assert!(!created.alias.is_empty());
|
||||
assert!(
|
||||
!p.identities()
|
||||
.iter()
|
||||
.any(|(_, alias)| *alias == created.alias)
|
||||
);
|
||||
// Remove only the independently expected two additions. Any incidental
|
||||
// mutation, clone of target children or extra correspondence fails here.
|
||||
let mut retained = after.clone();
|
||||
retained.elements.remove(&target);
|
||||
retained.traces.remove(&trace);
|
||||
assert_eq!(retained, *p);
|
||||
round_trip(&after);
|
||||
// Replaying a creation plan after acceptance violates its snapshot, while
|
||||
// a fresh unchanged request reuses the accepted counterpart.
|
||||
assert!(
|
||||
apply_transition(&after, &plan)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("stale")
|
||||
);
|
||||
unchanged_retry(&after, direction, source, target);
|
||||
(after, target)
|
||||
}
|
||||
|
||||
fn target_deletion(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
|
||||
let old_plan = plan_transition(&fixture.project, &request).unwrap();
|
||||
assert!(old_plan.conflicts.is_empty());
|
||||
let expected = expected_deletion(&fixture, &fixture.target);
|
||||
let mut session = Session::new(fixture.project.clone());
|
||||
session
|
||||
.execute(Command::Delete(fixture.target.root))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
round_trip(session.project());
|
||||
let revision = session.revision();
|
||||
assert!(
|
||||
apply_transition(session.project(), &old_plan)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("stale")
|
||||
);
|
||||
let explicit_plan = plan_transition(
|
||||
session.project(),
|
||||
&explicit(direction, fixture.source.root, fixture.target.root),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
explicit_plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(fixture.source.root)
|
||||
&& conflict.target == Some(fixture.target.root)
|
||||
&& conflict.field.is_none())
|
||||
);
|
||||
assert!(apply_transition(session.project(), &explicit_plan).is_err());
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_eq!(session.revision(), revision);
|
||||
let (created, _) = implicit_creation(
|
||||
session.project(),
|
||||
direction,
|
||||
fixture.source.root,
|
||||
fixture.target.root,
|
||||
);
|
||||
session.execute(Command::Replace(created.clone())).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &fixture.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &created);
|
||||
round_trip(session.project());
|
||||
}
|
||||
|
||||
fn source_recreation(direction: TransitionDirection) {
|
||||
let fixture = fixture(direction);
|
||||
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
|
||||
replacement.id = Id::new_v4();
|
||||
let source = replacement.id;
|
||||
// Same alias, name and prose, different UUID: name matching must never
|
||||
// silently relink the retained engineering design.
|
||||
assert_ne!(source, fixture.source.root);
|
||||
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
|
||||
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
|
||||
let recreated =
|
||||
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
|
||||
let mut expected = deleted.clone();
|
||||
expected.elements.insert(source, replacement);
|
||||
assert_eq!(recreated, expected);
|
||||
round_trip(&recreated);
|
||||
let (implicit, new_target) =
|
||||
implicit_creation(&recreated, direction, source, fixture.target.root);
|
||||
assert!(implicit.elements.contains_key(&fixture.target.root));
|
||||
assert_ne!(new_target, fixture.target.root);
|
||||
|
||||
// The explicit branch starts from exactly the same recreated project.
|
||||
let mut request = explicit(direction, source, fixture.target.root);
|
||||
let unreviewed = plan_transition(&recreated, &request).unwrap();
|
||||
for field in [TransitionField::Name, TransitionField::Description] {
|
||||
assert!(
|
||||
unreviewed
|
||||
.conflicts
|
||||
.iter()
|
||||
.any(|conflict| conflict.source == Some(source)
|
||||
&& conflict.target == Some(fixture.target.root)
|
||||
&& conflict.field == Some(field))
|
||||
);
|
||||
}
|
||||
assert!(apply_transition(&recreated, &unreviewed).is_err());
|
||||
request.resolutions = vec![
|
||||
FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Name,
|
||||
decision: FieldDecision::TakeSource,
|
||||
},
|
||||
FieldResolution {
|
||||
source,
|
||||
field: TransitionField::Description,
|
||||
decision: FieldDecision::KeepTarget,
|
||||
},
|
||||
];
|
||||
let plan = plan_transition(&recreated, &request).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.changes.len(), 1);
|
||||
assert_eq!(
|
||||
(plan.changes[0].target, plan.changes[0].action),
|
||||
(fixture.target.root, TransitionAction::Update)
|
||||
);
|
||||
assert_eq!(
|
||||
plan.changes[0].fields,
|
||||
vec![FieldChange {
|
||||
field: TransitionField::Name,
|
||||
before: recreated.elements[&fixture.target.root].name.clone(),
|
||||
after: recreated.elements[&source].name.clone(),
|
||||
}]
|
||||
);
|
||||
let relinked = apply_transition(&recreated, &plan).unwrap();
|
||||
let new_trace = assert_only_new_trace(
|
||||
&recreated,
|
||||
&relinked,
|
||||
direction,
|
||||
source,
|
||||
fixture.target.root,
|
||||
);
|
||||
assert_ne!(
|
||||
new_trace, fixture.trace,
|
||||
"Deleted correspondence is not silently resurrected"
|
||||
);
|
||||
let mut expected = recreated.clone();
|
||||
expected
|
||||
.elements
|
||||
.get_mut(&fixture.target.root)
|
||||
.unwrap()
|
||||
.name = recreated.elements[&source].name.clone();
|
||||
let mut without_trace = relinked.clone();
|
||||
without_trace.traces.remove(&new_trace);
|
||||
assert_eq!(
|
||||
without_trace, expected,
|
||||
"Only the reviewed target name may change; rich target design survives"
|
||||
);
|
||||
round_trip(&relinked);
|
||||
unchanged_retry(&relinked, direction, source, fixture.target.root);
|
||||
let original_source = round_trip(&recreated);
|
||||
let mut session = Session::new(recreated.clone());
|
||||
session.execute(Command::Replace(relinked)).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &recreated);
|
||||
assert_eq!(round_trip(session.project()), original_source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
|
||||
source_deletion(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
|
||||
source_deletion(TransitionDirection::SaToOa);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
|
||||
target_deletion(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
|
||||
target_deletion(TransitionDirection::SaToOa);
|
||||
}
|
||||
#[test]
|
||||
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
|
||||
source_recreation(TransitionDirection::OaToSa);
|
||||
}
|
||||
#[test]
|
||||
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
|
||||
source_recreation(TransitionDirection::SaToOa);
|
||||
}
|
||||
@@ -210,7 +210,6 @@ impl SemanticPlan {
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.candidate
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &ViewIndex {
|
||||
&self.index
|
||||
}
|
||||
@@ -485,6 +484,57 @@ pub struct PresentationContext {
|
||||
pub target: Option<TargetPresentation>,
|
||||
}
|
||||
|
||||
impl PresentationContext {
|
||||
fn header_visible(self, header: Rect, outgoing: bool) -> bool {
|
||||
if !outgoing {
|
||||
return self.viewport.contains(header);
|
||||
}
|
||||
// Leaving a performer must not require fitting the entire work card
|
||||
// inside the camera. Retain a 24px grab region so the result stays
|
||||
// directly reachable; geometry/model admission is independent.
|
||||
let width = (header.position.x + header.size.width)
|
||||
.min(self.viewport.position.x + self.viewport.size.width)
|
||||
- header.position.x.max(self.viewport.position.x);
|
||||
let height = (header.position.y + header.size.height)
|
||||
.min(self.viewport.position.y + self.viewport.size.height)
|
||||
- header.position.y.max(self.viewport.position.y);
|
||||
width * self.zoom >= 24. && height * self.zoom >= 24.
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_header_retains_a_grab_region_without_requiring_full_camera_containment() {
|
||||
let context = PresentationContext {
|
||||
viewport: Rect {
|
||||
position: Position { x: 0., y: 0. },
|
||||
size: Size {
|
||||
width: 800.,
|
||||
height: 400.,
|
||||
},
|
||||
},
|
||||
zoom: 0.75,
|
||||
target: None,
|
||||
};
|
||||
let mut header = Rect {
|
||||
position: Position { x: -100., y: 30. },
|
||||
size: Size {
|
||||
width: 224.,
|
||||
height: 62.,
|
||||
},
|
||||
};
|
||||
assert!(context.header_visible(header, true));
|
||||
assert!(!context.header_visible(header, false));
|
||||
header.position.x = -192.; // exactly 32 world pixels = 24 logical pixels
|
||||
assert!(context.header_visible(header, true));
|
||||
header.position.x -= 1.;
|
||||
assert!(!context.header_visible(header, true));
|
||||
header.position.x = 0.;
|
||||
header.position.y = 368.;
|
||||
assert!(context.header_visible(header, true));
|
||||
header.position.y += 1.;
|
||||
assert!(!context.header_visible(header, true));
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlacementPlan {
|
||||
pub diagram: Diagram,
|
||||
@@ -496,8 +546,8 @@ pub struct PlacementPlan {
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
/// `requested` is the original selected placement position plus pointer motion
|
||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
||||
/// `requested` preserves the frozen pointer offset unless the caller proposes
|
||||
/// bounded outward clearance for an outgoing drop. Snap only
|
||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||
pub fn place(
|
||||
@@ -577,7 +627,7 @@ pub fn place(
|
||||
height: selected.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !presentation.viewport.contains(header) {
|
||||
if !presentation.header_visible(header, plan.destination == Destination::OutsidePerformers) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
|
||||
@@ -188,7 +188,7 @@ pub fn place(
|
||||
};
|
||||
geometry::check_rigid(&plan.before_expanded, &expanded, &plan.placements, delta)?;
|
||||
scenes?;
|
||||
geometry::check_headers(&view, &plan.selected, presentation.viewport)?;
|
||||
geometry::check_headers(&view, &plan.selected, presentation, false)?;
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
|
||||
@@ -398,7 +398,12 @@ pub fn place(
|
||||
&expanded,
|
||||
)?;
|
||||
geometry::check_rigid(&scope.0.before_expanded, &expanded, scope.affected(), delta)?;
|
||||
geometry::check_headers(&view, scope.selected(), presentation.viewport)?;
|
||||
geometry::check_headers(
|
||||
&view,
|
||||
scope.selected(),
|
||||
presentation,
|
||||
plan.destination == Destination::OutsidePerformers,
|
||||
)?;
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
|
||||
@@ -170,7 +170,8 @@ pub(super) fn check_rigid(
|
||||
pub(super) fn check_headers(
|
||||
view: &ProjectedView,
|
||||
selected: &BTreeMap<Id, Id>,
|
||||
viewport: Rect,
|
||||
presentation: PresentationContext,
|
||||
outgoing: bool,
|
||||
) -> Result<(), Refusal> {
|
||||
for (id, element) in selected {
|
||||
let node = view
|
||||
@@ -185,7 +186,7 @@ pub(super) fn check_headers(
|
||||
height: node.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !viewport.contains(header) {
|
||||
if !presentation.header_visible(header, outgoing) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
|
||||
+711
-56
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
mod clearance;
|
||||
|
||||
pub const FINISH_EDIT: &str =
|
||||
"Finish or cancel the current edit before selecting or moving elements.";
|
||||
|
||||
@@ -56,6 +58,7 @@ pub struct Target {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frozen {
|
||||
pub targets: Vec<Target>,
|
||||
pub moving: Vec<Rectangle>,
|
||||
cards: Vec<Rectangle>,
|
||||
controls: Vec<Rectangle>,
|
||||
edges: Vec<(Point, Point)>,
|
||||
@@ -82,14 +85,16 @@ impl Frozen {
|
||||
) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
let mut moving = Vec::new();
|
||||
for n in &view.nodes {
|
||||
if affected.contains_key(&n.id) {
|
||||
continue;
|
||||
}
|
||||
let region = Rectangle::new(
|
||||
Point::new(n.position.x, n.position.y),
|
||||
iced::Size::new(n.size.width, n.size.height),
|
||||
);
|
||||
if affected.contains_key(&n.id) {
|
||||
moving.push(region);
|
||||
continue;
|
||||
}
|
||||
let Some(e) = project.elements.get(&n.element) else {
|
||||
cards.push(region);
|
||||
continue;
|
||||
@@ -172,6 +177,7 @@ impl Frozen {
|
||||
}
|
||||
Self {
|
||||
targets,
|
||||
moving,
|
||||
cards,
|
||||
controls,
|
||||
edges,
|
||||
@@ -407,7 +413,6 @@ impl Candidate {
|
||||
Self::Performers(p) => &p.accepted,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &view_options::ViewIndex {
|
||||
match self {
|
||||
Self::Singleton(p) => p.index(),
|
||||
@@ -525,6 +530,8 @@ pub(crate) struct Feedback {
|
||||
pub inspection_targets: Vec<usize>,
|
||||
pub text: String,
|
||||
pub refused: bool,
|
||||
/// Pointer-following outline only; it never authorizes a model change.
|
||||
pub ghost_delta: Option<Vector>,
|
||||
}
|
||||
impl Feedback {
|
||||
pub fn inspection_identity(&self) -> String {
|
||||
@@ -1055,7 +1062,26 @@ impl App {
|
||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||
target: target.map(|i| plan::TargetPresentation { placement: active.frozen.as_ref().unwrap().targets[i].placement }),
|
||||
};
|
||||
let placed = candidate.place(requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let exact = candidate.place(requested, presentation);
|
||||
let placed = if destination == plan::Destination::OutsidePerformers
|
||||
&& matches!(&exact, Err(plan::Refusal::LayoutRequired(_)))
|
||||
{
|
||||
let affected = match &active.prepared {
|
||||
Prepared::Singleton(scope) => Some(&scope.placements),
|
||||
Prepared::Assignment(scope) => Some(scope.affected()),
|
||||
_ => None,
|
||||
};
|
||||
affected.and_then(|affected| {
|
||||
clearance::requests(
|
||||
(self.project(), self.document.view_index()),
|
||||
(candidate.project(), candidate.index()), stamp.context.diagram,
|
||||
affected, *anchor, requested,
|
||||
(stamp.world(press.start), stamp.world(pointer.unwrap())),
|
||||
).into_iter().find_map(|position| candidate.place(position, presentation).ok())
|
||||
}).map_or(exact, Ok)
|
||||
} else {
|
||||
exact
|
||||
}.map_err(|r| refusal(&r))?;
|
||||
let label = candidate.label();
|
||||
if release {
|
||||
if let Some(command) = candidate.finish(self.project(), &placed).map_err(|r| refusal(&r))? {
|
||||
@@ -1082,6 +1108,14 @@ impl App {
|
||||
} else {
|
||||
let inspection_targets = active.inspection_targets.clone();
|
||||
let frozen = active.frozen.clone();
|
||||
// A rejected intermediate position is not a return gesture. Keep the
|
||||
// last admitted scene and show current motion separately; release
|
||||
// always recomputes admission and never commits this retained view.
|
||||
if refused && in_bounds && preview.is_none() {
|
||||
preview = self.allocation_drag.preview.take();
|
||||
}
|
||||
let ghost_delta = (refused && in_bounds)
|
||||
.then(|| (pointer.unwrap() - press.start) * (1. / stamp.zoom));
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
@@ -1092,6 +1126,7 @@ impl App {
|
||||
inspection_targets,
|
||||
text,
|
||||
refused,
|
||||
ghost_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,595 @@
|
||||
//! Bounded outgoing clearance proposals. The ordinary placement and semantic
|
||||
//! validators still decide whether any proposal may be previewed or committed.
|
||||
use iced::Point;
|
||||
use oa_core::{Id, NodePlacement, Position, Project, projection, view_options::ViewIndex};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const GRID: f32 = 10.;
|
||||
const GAP: f32 = 10.;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Bounds {
|
||||
left: f32,
|
||||
top: f32,
|
||||
right: f32,
|
||||
bottom: f32,
|
||||
}
|
||||
|
||||
impl Bounds {
|
||||
fn node(node: &projection::ProjectedNode) -> Option<Self> {
|
||||
let result = Self {
|
||||
left: node.position.x,
|
||||
top: node.position.y,
|
||||
right: node.position.x + node.size.width,
|
||||
bottom: node.position.y + node.size.height,
|
||||
};
|
||||
([result.left, result.top, result.right, result.bottom]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
&& result.right > result.left
|
||||
&& result.bottom > result.top)
|
||||
.then_some(result)
|
||||
}
|
||||
|
||||
fn union(self, other: Self) -> Self {
|
||||
Self {
|
||||
left: self.left.min(other.left),
|
||||
top: self.top.min(other.top),
|
||||
right: self.right.max(other.right),
|
||||
bottom: self.bottom.max(other.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
fn overlaps(self, other: Self) -> bool {
|
||||
self.left < other.right
|
||||
&& other.left < self.right
|
||||
&& self.top < other.bottom
|
||||
&& other.top < self.bottom
|
||||
}
|
||||
}
|
||||
|
||||
/// Called only after an OutsidePerformers candidate fails ordinary placement.
|
||||
/// Coordinates use the frozen world transform. The entire affected footprint
|
||||
/// receives one anchor translation; this never moves or packs other cards.
|
||||
pub(super) fn requests(
|
||||
accepted: (&Project, &ViewIndex),
|
||||
candidate: (&Project, &ViewIndex),
|
||||
diagram: Id,
|
||||
affected: &BTreeMap<Id, NodePlacement>,
|
||||
anchor: Id,
|
||||
requested: Position,
|
||||
motion: (Point, Point),
|
||||
) -> Vec<Position> {
|
||||
let (accepted, accepted_index) = accepted;
|
||||
let (candidate, candidate_index) = candidate;
|
||||
let (start, pointer) = motion;
|
||||
if ![
|
||||
requested.x,
|
||||
requested.y,
|
||||
start.x,
|
||||
start.y,
|
||||
pointer.x,
|
||||
pointer.y,
|
||||
]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
let (Some(original), Some(base), Some(anchor)) = (
|
||||
accepted.diagrams.get(&diagram),
|
||||
candidate.diagrams.get(&diagram),
|
||||
affected.get(&anchor),
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let snapped = Position {
|
||||
x: (requested.x / GRID).round() * GRID,
|
||||
y: (requested.y / GRID).round() * GRID,
|
||||
};
|
||||
let delta = Position {
|
||||
x: snapped.x - anchor.position.x,
|
||||
y: snapped.y - anchor.position.y,
|
||||
};
|
||||
let before = projection::project_diagram_with_index(accepted, original, accepted_index);
|
||||
let before_nodes: BTreeMap<_, _> = before.nodes.iter().map(|node| (node.id, node)).collect();
|
||||
let mut sources = BTreeSet::new();
|
||||
for node in before
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| affected.contains_key(&node.id))
|
||||
{
|
||||
let mut parent = node.visual_parent;
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = parent {
|
||||
if !seen.insert(id) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(node) = before_nodes.get(&id) else {
|
||||
break;
|
||||
};
|
||||
if !affected.contains_key(&id)
|
||||
&& accepted
|
||||
.elements
|
||||
.get(&node.element)
|
||||
.is_some_and(|element| element.kind().is_performer())
|
||||
{
|
||||
sources.insert(id);
|
||||
}
|
||||
parent = node.visual_parent;
|
||||
}
|
||||
}
|
||||
if sources.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Projection after responsibility removal supplies the former performer's
|
||||
// remaining enclosure, including other allocated work. Projecting the moved
|
||||
// block also includes the full visible descendant enclosure of every root.
|
||||
let mut moved = base.clone();
|
||||
for (id, captured) in affected {
|
||||
let Some(node) = moved.nodes.get_mut(id) else {
|
||||
return Vec::new();
|
||||
};
|
||||
node.position = Position {
|
||||
x: captured.position.x + delta.x,
|
||||
y: captured.position.y + delta.y,
|
||||
};
|
||||
if ![
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let after = projection::project_diagram_with_index(candidate, &moved, candidate_index);
|
||||
let mut footprint: Option<Bounds> = None;
|
||||
for node in after
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| affected.contains_key(&node.id))
|
||||
{
|
||||
let Some(bounds) = Bounds::node(node) else {
|
||||
return Vec::new();
|
||||
};
|
||||
footprint = Some(footprint.map_or(bounds, |previous| previous.union(bounds)));
|
||||
}
|
||||
let Some(footprint) = footprint else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut overlaps = Vec::new();
|
||||
for node in after.nodes.iter().filter(|node| sources.contains(&node.id)) {
|
||||
let (Some(old), Some(current)) = (Bounds::node(before_nodes[&node.id]), Bounds::node(node))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if footprint.overlaps(current) {
|
||||
overlaps.push((old, current));
|
||||
}
|
||||
}
|
||||
if overlaps.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Each entry is a half-plane actually crossed by the pointer. At a corner,
|
||||
// prefer the boundary reached first on the press-to-pointer segment. The
|
||||
// direction index is a stable tie-break, independent of placement UUIDs.
|
||||
let mut crossings = [None::<f32>; 4];
|
||||
for (old, _) in &overlaps {
|
||||
for (direction, initial, final_, boundary, outward) in [
|
||||
(
|
||||
0,
|
||||
start.x,
|
||||
pointer.x,
|
||||
old.left,
|
||||
pointer.x < old.left && start.x >= old.left,
|
||||
),
|
||||
(
|
||||
1,
|
||||
start.x,
|
||||
pointer.x,
|
||||
old.right,
|
||||
pointer.x >= old.right && start.x < old.right,
|
||||
),
|
||||
(
|
||||
2,
|
||||
start.y,
|
||||
pointer.y,
|
||||
old.top,
|
||||
pointer.y < old.top && start.y >= old.top,
|
||||
),
|
||||
(
|
||||
3,
|
||||
start.y,
|
||||
pointer.y,
|
||||
old.bottom,
|
||||
pointer.y >= old.bottom && start.y < old.bottom,
|
||||
),
|
||||
] {
|
||||
if outward {
|
||||
let fraction = (boundary - initial) / (final_ - initial);
|
||||
if fraction.is_finite() && (0. ..=1.).contains(&fraction) {
|
||||
crossings[direction] =
|
||||
Some(crossings[direction].map_or(fraction, |old| old.min(fraction)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut directions: Vec<_> = crossings
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(direction, fraction)| fraction.map(|fraction| (direction, fraction)))
|
||||
.collect();
|
||||
directions.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.0.cmp(&b.0)));
|
||||
let mut result = Vec::new();
|
||||
for (direction, _) in directions {
|
||||
let mut corrected = snapped;
|
||||
match direction {
|
||||
0 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.left)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
corrected.x = ((snapped.x + edge - GAP - footprint.right) / GRID).floor() * GRID;
|
||||
}
|
||||
1 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.right)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
corrected.x = ((snapped.x + edge + GAP - footprint.left) / GRID).ceil() * GRID;
|
||||
}
|
||||
2 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.top)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
corrected.y = ((snapped.y + edge - GAP - footprint.bottom) / GRID).floor() * GRID;
|
||||
}
|
||||
3 => {
|
||||
let edge = overlaps
|
||||
.iter()
|
||||
.map(|(_, current)| current.bottom)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
corrected.y = ((snapped.y + edge + GAP - footprint.top) / GRID).ceil() * GRID;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
if corrected.x.is_finite() && corrected.y.is_finite() && !result.contains(&corrected) {
|
||||
result.push(corrected);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::allocation_drag as plan;
|
||||
use oa_core::{Kind, Layer, ProjectionKind, RelationKind, Size};
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
performer: Id,
|
||||
work: Id,
|
||||
node: Id,
|
||||
}
|
||||
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer) -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let (performer, work) = match layer {
|
||||
Layer::Oa => (Kind::Entity, Kind::Activity),
|
||||
Layer::Sa => (Kind::System, Kind::Function),
|
||||
};
|
||||
let performer = project
|
||||
.add_element(performer, layer, "Performer", None)
|
||||
.unwrap();
|
||||
let work = project.add_element(work, layer, "Work", None).unwrap();
|
||||
let diagram = project.ensure_diagram(layer);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let performer = project.place(diagram, performer, xy(100., 100.));
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&performer)
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 400.,
|
||||
height: 250.,
|
||||
};
|
||||
let node = project.place(diagram, work, xy(160., 180.));
|
||||
let owner = project.diagrams[&diagram].nodes[&performer].element;
|
||||
project.relate(RelationKind::Allocation, "Responsibility", owner, work);
|
||||
Fixture {
|
||||
project,
|
||||
diagram,
|
||||
performer,
|
||||
work,
|
||||
node,
|
||||
}
|
||||
}
|
||||
|
||||
fn semantic(f: &Fixture) -> (plan::Scope, plan::SemanticPlan) {
|
||||
let scope = plan::capture(&f.project, f.diagram, f.work, f.node).unwrap();
|
||||
let semantic = plan::plan_semantics(
|
||||
&f.project,
|
||||
&scope,
|
||||
plan::Destination::OutsidePerformers,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
(scope, semantic)
|
||||
}
|
||||
|
||||
fn presentation() -> plan::PresentationContext {
|
||||
plan::PresentationContext {
|
||||
viewport: plan::Rect {
|
||||
position: xy(-2000., -2000.),
|
||||
size: Size {
|
||||
width: 5000.,
|
||||
height: 5000.,
|
||||
},
|
||||
},
|
||||
zoom: 1.,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_outgoing_edge_clears_the_source_and_passes_ordinary_placement() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let f = fixture(layer);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
for (start, pointer, requested, expected) in [
|
||||
(
|
||||
Point::new(170., 210.),
|
||||
Point::new(99., 210.),
|
||||
xy(89., 180.),
|
||||
xy(-140., 180.),
|
||||
),
|
||||
(
|
||||
Point::new(340., 210.),
|
||||
Point::new(501., 210.),
|
||||
xy(321., 180.),
|
||||
xy(510., 180.),
|
||||
),
|
||||
(
|
||||
Point::new(200., 190.),
|
||||
Point::new(200., 99.),
|
||||
xy(160., 89.),
|
||||
xy(160., -10.),
|
||||
),
|
||||
(
|
||||
Point::new(200., 260.),
|
||||
Point::new(200., 351.),
|
||||
xy(160., 271.),
|
||||
xy(160., 360.),
|
||||
),
|
||||
] {
|
||||
assert!(matches!(
|
||||
plan::place(&semantic, requested, presentation()),
|
||||
Err(plan::Refusal::LayoutRequired(_))
|
||||
));
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
requested,
|
||||
(start, pointer),
|
||||
);
|
||||
assert_eq!(proposed, vec![expected]);
|
||||
let placed = plan::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
assert!(
|
||||
plan::finish(&semantic, &f.project, &placed)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_shrink_uses_candidate_enclosure_not_frozen_target() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.performer)
|
||||
.unwrap()
|
||||
.size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
let (scope, semantic) = semantic(&f);
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
xy(229., 180.),
|
||||
(Point::new(340., 210.), Point::new(409., 210.)),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(340., 180.)]);
|
||||
plan::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_order_follows_boundary_crossing_and_is_deterministic() {
|
||||
let f = fixture(Layer::Sa);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
// Right crossed at 160/161 of the segment; bottom at 90/91.
|
||||
let args = (
|
||||
xy(321., 271.),
|
||||
Point::new(340., 260.),
|
||||
Point::new(501., 351.),
|
||||
);
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
args.0,
|
||||
(args.1, args.2),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(320., 360.), xy(510., 270.)]);
|
||||
assert_eq!(
|
||||
proposed,
|
||||
requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
args.0,
|
||||
(args.1, args.2)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_crossing_no_overlap_no_source_and_nonfinite_requests_are_empty() {
|
||||
let f = fixture(Layer::Oa);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
for (requested, pointer) in [
|
||||
(xy(170., 180.), Point::new(210., 210.)),
|
||||
(xy(700., 180.), Point::new(740., 210.)),
|
||||
(xy(f32::NAN, 180.), Point::new(99., 210.)),
|
||||
(xy(89., 180.), Point::new(f32::INFINITY, 210.)),
|
||||
] {
|
||||
assert!(
|
||||
requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
requested,
|
||||
(Point::new(200., 210.), pointer)
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
requests(
|
||||
(semantic.project(), semantic.index()),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
xy(89., 180.),
|
||||
(Point::new(200., 210.), Point::new(99., 210.))
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descendant_footprint_controls_clearance_without_mutating_projects() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let kind = f.project.elements[&f.work].kind();
|
||||
let child = f
|
||||
.project
|
||||
.add_element(kind, layer, "Child", Some(f.work))
|
||||
.unwrap();
|
||||
let child_node = f.project.place(f.diagram, child, xy(240., 270.));
|
||||
// First-child allocation transfer is normally performed by commands;
|
||||
// this fixture expresses the resulting leaf allocation directly.
|
||||
f.project.relations.clear();
|
||||
let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element;
|
||||
f.project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Leaf responsibility",
|
||||
owner,
|
||||
child,
|
||||
);
|
||||
let (scope, semantic) = semantic(&f);
|
||||
assert!(scope.placements.contains_key(&child_node));
|
||||
let before = f.project.clone();
|
||||
let candidate = semantic.project().clone();
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(&candidate, semantic.index()),
|
||||
f.diagram,
|
||||
&scope.placements,
|
||||
f.node,
|
||||
xy(89., 180.),
|
||||
(Point::new(170., 210.), Point::new(99., 210.)),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(-240., 180.)]);
|
||||
let placed = plan::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
let root = &placed.diagram.nodes[&f.node];
|
||||
let child = &placed.diagram.nodes[&child_node];
|
||||
assert_eq!(child.position.x - root.position.x, 80.);
|
||||
assert_eq!(child.position.y - root.position.y, 90.);
|
||||
assert_eq!(f.project, before);
|
||||
assert_eq!(semantic.project(), &candidate);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_selected_roots_keep_one_rigid_group_translation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let kind = f.project.elements[&f.work].kind();
|
||||
let second = f
|
||||
.project
|
||||
.add_element(kind, layer, "Second work", None)
|
||||
.unwrap();
|
||||
let second_node = f.project.place(f.diagram, second, xy(240., 300.));
|
||||
let owner = f.project.diagrams[&f.diagram].nodes[&f.performer].element;
|
||||
f.project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Second responsibility",
|
||||
owner,
|
||||
second,
|
||||
);
|
||||
let selected = BTreeMap::from([(f.node, f.work), (second_node, second)]);
|
||||
let scope =
|
||||
plan::assignment::capture(&f.project, f.diagram, &selected, f.node).unwrap();
|
||||
let semantic = plan::assignment::plan_semantics(
|
||||
&scope,
|
||||
plan::Destination::OutsidePerformers,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let proposed = requests(
|
||||
(&f.project, &ViewIndex::new(&f.project)),
|
||||
(semantic.project(), semantic.index()),
|
||||
f.diagram,
|
||||
scope.affected(),
|
||||
f.node,
|
||||
xy(89., 180.),
|
||||
(Point::new(170., 210.), Point::new(99., 210.)),
|
||||
);
|
||||
assert_eq!(proposed, vec![xy(-220., 180.)]);
|
||||
let placed = plan::assignment::place(&semantic, proposed[0], presentation()).unwrap();
|
||||
let root = &placed.diagram.nodes[&f.node];
|
||||
let second = &placed.diagram.nodes[&second_node];
|
||||
assert_eq!(second.position.x - root.position.x, 80.);
|
||||
assert_eq!(second.position.y - root.position.y, 120.);
|
||||
assert!(
|
||||
plan::assignment::finish(&semantic, &f.project, &placed)
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,117 @@ async fn mount() -> (Fixture, Mounted) {
|
||||
mounted.settle(&mut f.app).await;
|
||||
(f, mounted)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_tree_outgoing_boundary_motion_preview_release_and_cancel() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = iced::Size::new(1440., 900.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = 0.75;
|
||||
f.app.pan = Vector::new(20., 15.);
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let original = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = at(&mut mounted, &f, xy(660., 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(from),
|
||||
)
|
||||
.await;
|
||||
// Separate input/update/layout turns expose the held boundary transition
|
||||
// that a one-batch distant drop cannot exercise.
|
||||
for x in [620., 560., 510., 499., 490.] {
|
||||
let to = at(&mut mounted, &f, xy(x, 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: to })],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.project(), &original);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(
|
||||
!f.app.allocation_drag.feedback.as_ref().unwrap().refused,
|
||||
"{layer:?} x={x}: {}",
|
||||
f.app.status
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
let _ = mounted.pixels(&f.app);
|
||||
}
|
||||
let expected = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.placement
|
||||
.diagram
|
||||
.clone();
|
||||
let to = at(&mut mounted, &f, xy(490., 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(&f.app.project().diagrams[&f.diagram], &expected);
|
||||
assert!(
|
||||
!f.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == f.work)
|
||||
);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let from = at(&mut mounted, &f, xy(660., 225.));
|
||||
let to = at(&mut mounted, &f, xy(490., 225.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
@@ -481,6 +592,7 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
||||
if pan.y == -100. {
|
||||
// A genuine clipping refusal in the same held gesture, then
|
||||
// recovery at the original exact point: no camera rebasing.
|
||||
let retained = f.app.allocation_drag.preview.clone().unwrap();
|
||||
let clipped = at(&mut mounted, &f, xy(160., 110.));
|
||||
mounted
|
||||
.batch(
|
||||
@@ -492,7 +604,19 @@ async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canva
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
f.app.allocation_drag.preview.as_ref().unwrap(),
|
||||
&retained
|
||||
));
|
||||
assert!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.ghost_delta
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
|
||||
@@ -1239,6 +1239,7 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
|
||||
inspection_targets: vec![],
|
||||
text: "A card blocks this drop".into(),
|
||||
refused: true,
|
||||
ghost_delta: None,
|
||||
};
|
||||
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
||||
feedback.text = "Release inside the captured canvas".into();
|
||||
@@ -1246,3 +1247,361 @@ fn destination_identity_does_not_describe_blocked_or_outside_viewport_as_unalloc
|
||||
feedback.refused = false;
|
||||
assert_eq!(feedback.destination_identity(), "Outside performers");
|
||||
}
|
||||
|
||||
fn outgoing_fixture(layer: Layer) -> Fixture {
|
||||
let mut f = fixture(layer);
|
||||
drag(&mut f, screen(xy(160., 190.)));
|
||||
let mut project = f.app.project().clone();
|
||||
let d = project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let performer = d.nodes.values_mut().find(|n| n.element == f.a).unwrap();
|
||||
performer.position = xy(500., 100.);
|
||||
performer.size = Size {
|
||||
width: 300.,
|
||||
height: 300.,
|
||||
};
|
||||
d.nodes.get_mut(&f.node).unwrap().position = xy(540., 190.);
|
||||
replace(&mut f, project);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_boundary_drop_clears_source_without_changing_other_placements() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = screen(xy(660., 225.));
|
||||
let to = screen(xy(490., 225.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().unwrap();
|
||||
let expected_diagram = preview.placement.diagram.clone();
|
||||
let work = &expected_diagram.nodes[&f.node];
|
||||
assert!(work.position.x + work.size.width <= 490.);
|
||||
assert_eq!(work.position.y, 190.);
|
||||
for (id, node) in &before.diagrams[&f.diagram].nodes {
|
||||
if *id != f.node {
|
||||
assert_eq!(&expected_diagram.nodes[id], node);
|
||||
}
|
||||
}
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(&f.app.project().diagrams[&f.diagram], &expected_diagram);
|
||||
assert!(
|
||||
!f.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation && r.target() == f.work)
|
||||
);
|
||||
let after = f.app.project().clone();
|
||||
let saved = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &after);
|
||||
assert_eq!(f.app.document.bundle(), &saved);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_clearance_collision_refuses_release_without_source_or_history_mutation() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
let obstacle = project
|
||||
.add_element(
|
||||
project.elements[&f.work].kind(),
|
||||
layer,
|
||||
"Unrelated work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let obstacle_node = project.place(f.diagram, obstacle, xy(240., 190.));
|
||||
replace(&mut f, project);
|
||||
// A refused release must preserve an existing redo branch, too.
|
||||
let _ = f.app.update(Message::Command(Command::Rename(
|
||||
f.work,
|
||||
"Temporary".into(),
|
||||
)));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let snapshot = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let from = screen(xy(660., 225.));
|
||||
let to = screen(xy(490., 225.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
let p = &active.press;
|
||||
let frozen = active.frozen.as_ref().unwrap();
|
||||
let owner = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
|
||||
assert_eq!(p.start, from);
|
||||
assert!(contains(owner.region, p.stamp.world(from)));
|
||||
assert!(p.stamp.world(to).x < owner.region.x);
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(to), false),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None),
|
||||
"The pointer must reach empty canvas, not be occluded by the obstacle"
|
||||
);
|
||||
let candidate = active
|
||||
.cache
|
||||
.iter()
|
||||
.find(|(destination, _)| *destination == plan::Destination::OutsidePerformers)
|
||||
.unwrap()
|
||||
.1
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
let affected = match &active.prepared {
|
||||
Prepared::Singleton(scope) => &scope.placements,
|
||||
Prepared::Assignment(scope) => scope.affected(),
|
||||
_ => panic!("expected assignment gesture"),
|
||||
};
|
||||
let requested = xy(370., 190.);
|
||||
let presentation = plan::PresentationContext {
|
||||
viewport: p.stamp.viewport(),
|
||||
zoom: p.stamp.zoom,
|
||||
target: None,
|
||||
};
|
||||
assert!(matches!(
|
||||
candidate.place(requested, presentation),
|
||||
Err(plan::Refusal::LayoutRequired(_))
|
||||
));
|
||||
let proposed = clearance::requests(
|
||||
(f.app.project(), f.app.document.view_index()),
|
||||
(candidate.project(), candidate.index()),
|
||||
f.diagram,
|
||||
affected,
|
||||
f.node,
|
||||
requested,
|
||||
(p.stamp.world(from), p.stamp.world(to)),
|
||||
);
|
||||
assert_eq!(
|
||||
proposed.len(),
|
||||
1,
|
||||
"a genuine left crossing must propose clearance"
|
||||
);
|
||||
assert!(proposed[0].x < requested.x);
|
||||
let work = &before.diagrams[&f.diagram].nodes[&f.node];
|
||||
let obstacle = &before.diagrams[&f.diagram].nodes[&obstacle_node];
|
||||
assert!(obstacle.position.x + obstacle.size.width < 490.);
|
||||
assert!(proposed[0].x + work.size.width <= owner.region.x - 10.);
|
||||
assert!(proposed[0].x < obstacle.position.x + obstacle.size.width);
|
||||
assert!(obstacle.position.x < proposed[0].x + work.size.width);
|
||||
assert_eq!(proposed[0].y, obstacle.position.y);
|
||||
assert!(matches!(
|
||||
candidate.place(proposed[0], presentation),
|
||||
Err(plan::Refusal::LayoutRequired(message)) if message.contains("overlapping cards")
|
||||
));
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.refused, "{}", feedback.text);
|
||||
assert!(feedback.ghost_delta.is_some());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
let _ = f.app.update(release);
|
||||
assert!(
|
||||
f.app.status.contains("overlapping cards"),
|
||||
"{}",
|
||||
f.app.status
|
||||
);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&snapshot, &f.app.document.snapshot()));
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_clearance_preserves_source_enclosure_and_allocated_sibling() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = outgoing_fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
let sibling = project
|
||||
.add_element(
|
||||
project.elements[&f.work].kind(),
|
||||
layer,
|
||||
"Remaining work",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sibling_node = project.place(f.diagram, sibling, xy(540., 450.));
|
||||
let allocation = project.relate(
|
||||
RelationKind::Allocation,
|
||||
"Keep responsibility",
|
||||
f.a,
|
||||
sibling,
|
||||
);
|
||||
replace(&mut f, project);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let view = projection::project_view(&before, f.diagram);
|
||||
let owner = view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
||||
let bottom = owner.position.y + owner.size.height;
|
||||
let stored_owner = &before.diagrams[&f.diagram].nodes[&owner.id];
|
||||
assert!(
|
||||
bottom > stored_owner.position.y + stored_owner.size.height,
|
||||
"The retained sibling must enlarge the projected source enclosure"
|
||||
);
|
||||
let from = screen(xy(660., 225.));
|
||||
let to = screen(xy(660., bottom + 1.));
|
||||
let mut events = batch(&f.app, from, to);
|
||||
let release = events.pop().unwrap();
|
||||
send(&mut f.app, events);
|
||||
let active = f.app.allocation_drag.active.as_ref().unwrap();
|
||||
let p = &active.press;
|
||||
let frozen = active.frozen.as_ref().unwrap();
|
||||
let target = frozen.targets.iter().find(|t| t.element == f.a).unwrap();
|
||||
assert_eq!(p.start, from);
|
||||
assert!(contains(target.region, p.stamp.world(from)));
|
||||
assert!(p.stamp.world(to).y >= target.region.y + target.region.height);
|
||||
assert_eq!(
|
||||
frozen.resolve(p.stamp, Some(to), false),
|
||||
Hit::Destination(plan::Destination::OutsidePerformers, None)
|
||||
);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
let requested = xy(540., 190. + to.y - from.y);
|
||||
assert!(matches!(
|
||||
preview.candidate.place(
|
||||
requested,
|
||||
plan::PresentationContext {
|
||||
viewport: p.stamp.viewport(),
|
||||
zoom: p.stamp.zoom,
|
||||
target: None,
|
||||
}
|
||||
),
|
||||
Err(plan::Refusal::LayoutRequired(_))
|
||||
));
|
||||
let moved = &preview.placement.diagram.nodes[&f.node];
|
||||
assert_eq!(moved.position.x, 540.);
|
||||
assert!(moved.position.y >= bottom + 10.);
|
||||
assert!(moved.position.y > requested.y);
|
||||
assert_eq!(
|
||||
preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == owner.id)
|
||||
.unwrap(),
|
||||
owner
|
||||
);
|
||||
let remaining = preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == sibling_node)
|
||||
.unwrap();
|
||||
assert_eq!(remaining.visual_parent, Some(owner.id));
|
||||
assert_eq!(
|
||||
remaining.position,
|
||||
before.diagrams[&f.diagram].nodes[&sibling_node].position
|
||||
);
|
||||
assert_eq!(
|
||||
&preview.project().relations[&allocation],
|
||||
&before.relations[&allocation]
|
||||
);
|
||||
for (id, node) in &before.diagrams[&f.diagram].nodes {
|
||||
if *id != f.node {
|
||||
assert_eq!(&preview.placement.diagram.nodes[id], node);
|
||||
}
|
||||
}
|
||||
let mut expected = preview.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
let _ = f.app.update(release);
|
||||
assert_eq!(f.app.project(), &expected, "{}", f.app.status);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), sibling),
|
||||
Some(f.a)
|
||||
);
|
||||
assert_eq!(
|
||||
&f.app.project().relations[&allocation],
|
||||
&before.relations[&allocation]
|
||||
);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_motion_retains_last_scene_but_release_never_commits_it() {
|
||||
let mut f = outgoing_fixture(Layer::Sa);
|
||||
let mut project = f.app.project().clone();
|
||||
let obstacle = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Unrelated work", None)
|
||||
.unwrap();
|
||||
project.place(f.diagram, obstacle, xy(1250., 120.));
|
||||
replace(&mut f, project);
|
||||
let original = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let p = admit_press(&mut f);
|
||||
let clear = screen(xy(450., 225.));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(clear),
|
||||
)));
|
||||
let last = f.app.allocation_drag.preview.clone().expect(&f.app.status);
|
||||
// Another work card blocks this location: the prior legal outgoing scene is display
|
||||
// state only, and must not become a fallback command on release.
|
||||
let blocked = screen(xy(1260., 130.));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Preview(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(blocked),
|
||||
)));
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.ghost_delta
|
||||
.is_some()
|
||||
);
|
||||
assert!(Arc::ptr_eq(
|
||||
f.app.allocation_drag.preview.as_ref().unwrap(),
|
||||
&last
|
||||
));
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Release(
|
||||
p.stamp,
|
||||
p.token,
|
||||
Some(blocked),
|
||||
)));
|
||||
assert_eq!(f.app.project(), &original);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use super::*;
|
||||
use crate::app::multiselect::{self, Choice};
|
||||
use crate::diagram_usage::Target as SelectionTarget;
|
||||
use iced::widget::text_editor;
|
||||
|
||||
#[path = "group/connection_lifetime.rs"]
|
||||
mod connection_lifetime;
|
||||
#[path = "group/route_lifetime.rs"]
|
||||
mod route_lifetime;
|
||||
|
||||
struct Group {
|
||||
f: Fixture,
|
||||
@@ -190,6 +196,165 @@ fn exact_history(
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_text_apply_rejects_old_allocation_packets_without_retiring_a_new_drag() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut g = group(layer);
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let before = g.f.app.document.snapshot();
|
||||
assert!(!g.f.app.document.can_undo());
|
||||
let from = node_point(&g.f.app, g.f.node);
|
||||
let mut old_canvas = CanvasState::default();
|
||||
let old = begin(&mut g.f.app, &mut old_canvas, from);
|
||||
assert!(g.f.app.allocation_drag.active.is_some());
|
||||
let location = g.f.app.document.compiled().index.declarations[&g.f.work].clone();
|
||||
let original = &before.bundle.files[&location.path];
|
||||
let quoted_name = format!("\"{}\"", g.f.app.project().elements[&g.f.work].name);
|
||||
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
|
||||
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.elements.get_mut(&g.f.work).unwrap().name = "Changed through Text".into();
|
||||
let mut expected_bundle = before.bundle.clone();
|
||||
expected_bundle
|
||||
.files
|
||||
.insert(location.path.clone(), edited.clone());
|
||||
|
||||
let _ = g.f.app.update(Message::Page(Page::Text));
|
||||
assert!(g.f.app.allocation_drag.active.is_none());
|
||||
let _ = g.f.app.update(Message::SourceFile(location.path));
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(edited.clone())),
|
||||
)));
|
||||
assert!(g.f.app.source_dirty());
|
||||
assert_eq!(g.f.app.source_editor.text(), edited);
|
||||
assert!(Arc::ptr_eq(&before, &g.f.app.document.snapshot()));
|
||||
let _ = g.f.app.update(Message::ApplySource);
|
||||
assert!(!g.f.app.source_dirty(), "{}", g.f.app.status);
|
||||
assert_eq!(g.f.app.project(), &expected);
|
||||
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
|
||||
|
||||
let _ = g.f.app.update(Message::Page(Page::Diagram));
|
||||
assert_eq!(g.f.app.active_diagram, Some(g.f.diagram));
|
||||
select(&mut g.f.app, &[g.f.node, g.yn]);
|
||||
let from = node_point(&g.f.app, g.f.node);
|
||||
let to = from + Vector::new(1400., 0.);
|
||||
let mut current_canvas = CanvasState::default();
|
||||
let current = begin(&mut g.f.app, &mut current_canvas, from);
|
||||
assert_ne!(current.token, old.token);
|
||||
assert_ne!(current.stamp.context, old.stamp.context);
|
||||
move_pointer(&mut g.f.app, &mut current_canvas, to);
|
||||
let preview =
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.expect(&g.f.app.status)
|
||||
.clone();
|
||||
let Candidate::Assignment(plan) = &preview.candidate else {
|
||||
panic!("the current gesture must be a real group assignment");
|
||||
};
|
||||
assert_eq!(plan.changed_count(), 2);
|
||||
let accepted = g.f.app.document.snapshot();
|
||||
let revision = g.f.app.document.revision();
|
||||
let held = selection(&g.f.app);
|
||||
let selected = g.f.app.multiselect_snapshot().unwrap();
|
||||
let workspace = g.f.app.source_workspace.bundle();
|
||||
let editor = g.f.app.source_editor.text();
|
||||
let camera = (
|
||||
g.f.app.zoom,
|
||||
g.f.app.pan,
|
||||
g.f.app.fit_to_view,
|
||||
g.f.app.canvas_size,
|
||||
);
|
||||
let epochs = (
|
||||
g.f.app.canvas_epoch,
|
||||
g.f.app.camera_epoch,
|
||||
g.f.app.allocation_drag.epoch,
|
||||
);
|
||||
let draft =
|
||||
g.f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = g.f.app.behavior.recovery_snapshot();
|
||||
let status = g.f.app.status.clone();
|
||||
let pending_reveal = g.f.app.pending_diagram_reveal.clone();
|
||||
let arrange = g.f.app.allocation_drag.arrange_next;
|
||||
// An idle obsolete press intentionally reports a stale-view explanation.
|
||||
// With a newer real gesture active, both old packets must be fully inert.
|
||||
for event in [
|
||||
Event::Press(old.clone()),
|
||||
Event::Release(old.stamp, old.token, Some(to)),
|
||||
] {
|
||||
let _ = g.f.app.update(Message::WorkDrag(event));
|
||||
assert!(Arc::ptr_eq(&accepted, &g.f.app.document.snapshot()));
|
||||
assert_eq!(g.f.app.project(), &expected);
|
||||
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
|
||||
assert_eq!(g.f.app.document.revision(), revision);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
assert_eq!(g.f.app.multiselect_snapshot(), Some(selected.clone()));
|
||||
assert!(Arc::ptr_eq(
|
||||
&g.f.app.allocation_drag.active.as_ref().unwrap().press,
|
||||
¤t
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
g.f.app.allocation_drag.preview.as_ref().unwrap(),
|
||||
&preview
|
||||
));
|
||||
assert_eq!(g.f.app.source_workspace.bundle(), workspace);
|
||||
assert_eq!(g.f.app.source_editor.text(), editor);
|
||||
assert_eq!(
|
||||
(
|
||||
g.f.app.zoom,
|
||||
g.f.app.pan,
|
||||
g.f.app.fit_to_view,
|
||||
g.f.app.canvas_size
|
||||
),
|
||||
camera
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
g.f.app.canvas_epoch,
|
||||
g.f.app.camera_epoch,
|
||||
g.f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
g.f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(g.f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(g.f.app.pending_diagram_reveal, pending_reveal);
|
||||
assert_eq!(g.f.app.allocation_drag.arrange_next, arrange);
|
||||
assert_eq!(g.f.app.status, status);
|
||||
assert!(!g.f.app.source_dirty());
|
||||
assert!(g.f.app.document.can_undo());
|
||||
assert!(!g.f.app.document.can_redo());
|
||||
}
|
||||
// The current gesture remains only a preview. History contains solely
|
||||
// the accepted text rename, never either stale assignment packet.
|
||||
let _ = g.f.app.update(Message::Undo);
|
||||
assert_eq!(g.f.app.project(), &before.decoded.project);
|
||||
assert_eq!(g.f.app.document.bundle(), &before.bundle);
|
||||
assert!(!g.f.app.document.can_undo());
|
||||
assert!(g.f.app.document.can_redo());
|
||||
let _ = g.f.app.update(Message::Redo);
|
||||
assert_eq!(g.f.app.project(), &expected);
|
||||
assert_eq!(g.f.app.document.bundle(), &expected_bundle);
|
||||
assert!(g.f.app.document.can_undo());
|
||||
assert!(!g.f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_split_rebuild_preserves_primary_and_commits_one_complete_candidate() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Delayed captured drawing packets must not retire a newer Allocation owner.
|
||||
use super::*;
|
||||
use crate::connection_edit::Event as ConnectionEvent;
|
||||
use crate::connection_tool::Tool;
|
||||
|
||||
fn drawing_tool(layer: Layer) -> Tool {
|
||||
match layer {
|
||||
Layer::Oa => Tool::Interaction,
|
||||
Layer::Sa => Tool::FunctionalExchange,
|
||||
}
|
||||
}
|
||||
|
||||
fn drawing_source(g: &mut Group, layer: Layer) -> crate::route_edit::Context {
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Choose(Some(
|
||||
drawing_tool(layer),
|
||||
))));
|
||||
let context = g.f.app.route_context().unwrap();
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(
|
||||
context, g.f.work,
|
||||
)));
|
||||
let session = g.f.app.connection.as_ref().unwrap();
|
||||
assert_eq!(session.context, context);
|
||||
assert_eq!(session.source, Some(g.f.work));
|
||||
assert!(session.candidates.contains(&g.y));
|
||||
assert_eq!(g.f.app.route_context(), Some(context));
|
||||
context
|
||||
}
|
||||
|
||||
fn assert_captured_noop(app: &mut App, captured: crate::route_edit::Context, target: Id) {
|
||||
for event in [
|
||||
ConnectionEvent::Endpoint(captured, target),
|
||||
ConnectionEvent::Restart(captured),
|
||||
] {
|
||||
assert_connection_noop(app, event);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_connection_noop(app: &mut App, event: ConnectionEvent) {
|
||||
let document = app.document.snapshot();
|
||||
let selected = selection(app);
|
||||
let context = app.route_context();
|
||||
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
|
||||
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let active = app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|a| (a.press.clone(), a.crossed, a.cache.len()));
|
||||
let preview = app.allocation_drag.preview.as_ref().map(|p| {
|
||||
(
|
||||
p.token,
|
||||
p.placement.diagram.clone(),
|
||||
p.placement.delta,
|
||||
p.project().clone(),
|
||||
p.routed.clone(),
|
||||
)
|
||||
});
|
||||
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
|
||||
(
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)
|
||||
});
|
||||
let connection = |app: &App| {
|
||||
app.connection
|
||||
.as_ref()
|
||||
.map(|s| (s.context, s.tool, s.source, s.candidates.clone()))
|
||||
};
|
||||
let session = connection(app);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(Message::Connection(event));
|
||||
assert_eq!(selection(app), selected);
|
||||
assert_eq!(app.route_context(), context);
|
||||
assert_eq!(
|
||||
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
ownership,
|
||||
"rejected drawing packet must not retire the newer input owner"
|
||||
);
|
||||
match (active, app.allocation_drag.active.as_ref()) {
|
||||
(Some((press, crossed, cached)), Some(current)) => {
|
||||
assert!(Arc::ptr_eq(&press, ¤t.press));
|
||||
assert_eq!((current.crossed, current.cache.len()), (crossed, cached));
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("rejected drawing packet changed the active Allocation token"),
|
||||
}
|
||||
match (preview, app.allocation_drag.preview.as_ref()) {
|
||||
(Some((token, diagram, delta, project, routed)), Some(current)) => {
|
||||
assert_eq!(current.token, token);
|
||||
assert_eq!(current.placement.diagram, diagram);
|
||||
assert_eq!(current.placement.delta, delta);
|
||||
assert_eq!(current.project(), &project);
|
||||
assert!(Arc::ptr_eq(¤t.routed, &routed));
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("rejected drawing packet changed the Allocation preview"),
|
||||
}
|
||||
assert_eq!(
|
||||
app.allocation_drag.feedback.as_ref().map(|f| (
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)),
|
||||
feedback
|
||||
);
|
||||
assert_eq!(connection(app), session);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), document.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_connection_after_cancel_preserves_arrange_arm_and_live_group() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut g = group(layer);
|
||||
let old = drawing_source(&mut g, layer);
|
||||
let _ = g.f.app.update(Message::Escape);
|
||||
assert!(g.f.app.connection.is_none());
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
assert!(g.f.app.allocation_drag.arrange_next);
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
assert_captured_noop(&mut g.f.app, old, g.y);
|
||||
// Matching context with no session isolates the missing-owner guard.
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let held = selection(&g.f.app);
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let to = from + Vector::new(400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
let press = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(press.mode, Mode::Arrange);
|
||||
assert!(!g.f.app.allocation_drag.arrange_next);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
assert!(matches!(
|
||||
g.f.app
|
||||
.allocation_drag
|
||||
.preview
|
||||
.as_ref()
|
||||
.map(|p| &p.candidate),
|
||||
Some(Candidate::Arrange(_))
|
||||
));
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
assert_captured_noop(&mut g.f.app, old, g.y);
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
|
||||
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
|
||||
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
|
||||
assert_eq!(selection(&g.f.app), held);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_connection_context_mismatches_preserve_the_current_session() {
|
||||
let mut g = group(Layer::Oa);
|
||||
let current = drawing_source(&mut g, Layer::Oa);
|
||||
// Session and current context agree: each old captured field is the sole
|
||||
// invalid relationship, not hidden behind a missing session.
|
||||
for captured in [
|
||||
crate::route_edit::Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
crate::route_edit::Context {
|
||||
diagram: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
crate::route_edit::Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
crate::route_edit::Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_captured_noop(&mut g.f.app, captured, g.y);
|
||||
}
|
||||
// Captured/session contexts agree, but the rendered view has advanced.
|
||||
// This isolates the live-route predicate from session ownership.
|
||||
g.f.app.canvas_epoch += 1;
|
||||
assert_ne!(g.f.app.route_context(), Some(current));
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
g.f.app.canvas_epoch = current.epoch;
|
||||
|
||||
// Conversely, incoming/current contexts agree. Retain obsolete session
|
||||
// metadata explicitly to isolate the session-context admission branch.
|
||||
g.f.app.connection.as_mut().unwrap().context.epoch += 1;
|
||||
assert_eq!(g.f.app.route_context(), Some(current));
|
||||
assert_captured_noop(&mut g.f.app, current, g.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_connection_current_restart_and_pair_commit_after_tool_boundary() {
|
||||
for begin_tool in [false, true] {
|
||||
let mut g = group(Layer::Oa);
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let mut state = CanvasState::default();
|
||||
begin(&mut g.f.app, &mut state, from);
|
||||
move_pointer(&mut g.f.app, &mut state, from + Vector::new(400., 0.));
|
||||
assert!(g.f.app.allocation_drag.preview.is_some());
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let epoch = g.f.app.allocation_drag.epoch;
|
||||
let event = if begin_tool {
|
||||
ConnectionEvent::Begin(Tool::Interaction, g.f.work)
|
||||
} else {
|
||||
ConnectionEvent::Choose(Some(Tool::Interaction))
|
||||
};
|
||||
let _ = g.f.app.update(Message::Connection(event));
|
||||
assert!(g.f.app.allocation_drag.epoch > epoch);
|
||||
assert!(g.f.app.allocation_drag.active.is_none());
|
||||
assert!(g.f.app.allocation_drag.preview.is_none());
|
||||
assert!(!g.f.app.allocation_drag.arrange_next);
|
||||
let context = g.f.app.route_context().unwrap();
|
||||
if !begin_tool {
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(
|
||||
context, g.f.work,
|
||||
)));
|
||||
}
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
|
||||
assert!(
|
||||
g.f.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&g.y)
|
||||
);
|
||||
let document = g.f.app.document.snapshot();
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Restart(context)));
|
||||
let context = g.f.app.route_context().unwrap();
|
||||
let restarted = g.f.app.connection.as_ref().unwrap();
|
||||
assert_eq!(restarted.context, context);
|
||||
assert_eq!(restarted.tool, Tool::Interaction);
|
||||
assert_eq!(restarted.source, None);
|
||||
assert!(restarted.candidates.contains(&g.f.work));
|
||||
assert!(Arc::ptr_eq(&document, &g.f.app.document.snapshot()));
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(
|
||||
context, g.f.work,
|
||||
)));
|
||||
assert_eq!(g.f.app.connection.as_ref().unwrap().source, Some(g.f.work));
|
||||
let revision = g.f.app.document.revision();
|
||||
let _ =
|
||||
g.f.app
|
||||
.update(Message::Connection(ConnectionEvent::Endpoint(context, g.y)));
|
||||
assert!(g.f.app.connection.is_none());
|
||||
assert_eq!(g.f.app.document.revision(), revision + 1);
|
||||
let relation = &g.f.app.project().relations[&g.f.app.selected];
|
||||
assert_eq!(relation.kind(), RelationKind::Interaction);
|
||||
assert_eq!((relation.source(), relation.target()), (g.f.work, g.y));
|
||||
let mut expected = before.clone();
|
||||
assert!(
|
||||
expected
|
||||
.relations
|
||||
.insert(relation.id, relation.clone())
|
||||
.is_none()
|
||||
);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Captured route commits cannot retire a newer Allocation input owner.
|
||||
use super::*;
|
||||
use crate::route_edit::{Context, Event as RouteEvent};
|
||||
|
||||
fn routed_group() -> (Group, Id) {
|
||||
let mut g = group(Layer::Oa);
|
||||
let mut project = g.f.app.project().clone();
|
||||
let edge = project.relate(
|
||||
RelationKind::Interaction,
|
||||
"Reviewed exchange",
|
||||
g.f.work,
|
||||
g.y,
|
||||
);
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&g.f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![xy(700., 400.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: xy(5., 10.),
|
||||
}),
|
||||
source_attachment: None,
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.6,
|
||||
}),
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
g.f.app = App::with_project(project);
|
||||
g.f.app.active_diagram = Some(g.f.diagram);
|
||||
g.f.app.zoom = 1.;
|
||||
g.f.app.pan = Vector::ZERO;
|
||||
g.f.app.fit_to_view = false;
|
||||
choose(&mut g.f.app, SelectionTarget::Edge(edge), Choice::Replace);
|
||||
assert_eq!(g.f.app.selected, edge);
|
||||
let view = projection::project_view(g.f.app.project(), g.f.diagram);
|
||||
assert!(view.edges.iter().any(|e| e.id == edge));
|
||||
assert_eq!(
|
||||
crate::route_edit::attachment_node(
|
||||
g.f.app.project(),
|
||||
&g.f.app.project().diagrams[&g.f.diagram],
|
||||
&view,
|
||||
edge,
|
||||
EdgeEnd::Source,
|
||||
)
|
||||
.unwrap()
|
||||
.id,
|
||||
g.f.node
|
||||
);
|
||||
(g, edge)
|
||||
}
|
||||
|
||||
fn packet(g: &Group, edge: Id, captured: Context, attachment: bool) -> RouteEvent {
|
||||
let mut route =
|
||||
crate::route_edit::stored_route(&g.f.app.project().diagrams[&g.f.diagram], edge);
|
||||
if attachment {
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
});
|
||||
RouteEvent::CommitAttachment(captured, route, EdgeEnd::Source, g.f.node)
|
||||
} else {
|
||||
route.waypoints[0] = xy(750., 450.);
|
||||
RouteEvent::Commit(captured, route, Some(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_route_noop(app: &mut App, event: RouteEvent) {
|
||||
let document = app.document.snapshot();
|
||||
let selected = selection(app);
|
||||
let context = app.route_context();
|
||||
let camera = (app.pan, app.zoom, app.canvas_epoch, app.camera_epoch);
|
||||
let ownership = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let press = app.allocation_drag.active.as_ref().map(|a| a.press.clone());
|
||||
let preview = app.allocation_drag.preview.clone();
|
||||
let feedback = app.allocation_drag.feedback.as_ref().map(|f| {
|
||||
(
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)
|
||||
});
|
||||
let handles = (app.selected_bend, app.selected_attachment);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(Message::Route(event));
|
||||
assert_eq!(selection(app), selected);
|
||||
assert_eq!(app.route_context(), context);
|
||||
assert_eq!(
|
||||
(app.pan, app.zoom, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
ownership
|
||||
);
|
||||
match (press, app.allocation_drag.active.as_ref()) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, &after.press)),
|
||||
(None, None) => {}
|
||||
_ => panic!("stale route packet changed the active Allocation token"),
|
||||
}
|
||||
match (preview, app.allocation_drag.preview.as_ref()) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(&before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("stale route packet changed the Allocation preview"),
|
||||
}
|
||||
assert_eq!(
|
||||
app.allocation_drag.feedback.as_ref().map(|f| (
|
||||
f.press.token,
|
||||
f.text.clone(),
|
||||
f.refused,
|
||||
f.targets.clone(),
|
||||
f.inspection_targets.clone(),
|
||||
)),
|
||||
feedback
|
||||
);
|
||||
assert_eq!((app.selected_bend, app.selected_attachment), handles);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&document, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), document.bundle);
|
||||
}
|
||||
|
||||
fn stale_route_preserves_new_group(attachment: bool, escape: bool) {
|
||||
let (mut g, edge) = routed_group();
|
||||
let old = g.f.app.route_context().unwrap();
|
||||
let delayed = packet(&g, edge, old, attachment);
|
||||
if escape {
|
||||
let _ = g.f.app.update(Message::Escape);
|
||||
assert_ne!(g.f.app.route_context(), Some(old));
|
||||
}
|
||||
select(&mut g.f.app, &[g.an, g.yn]);
|
||||
let _ = g.f.app.update(Message::WorkDrag(Event::ArrangeNext));
|
||||
assert!(g.f.app.allocation_drag.arrange_next);
|
||||
if !escape {
|
||||
assert_eq!(g.f.app.route_context(), Some(old));
|
||||
assert_ne!(g.f.app.selected, edge);
|
||||
}
|
||||
assert_route_noop(&mut g.f.app, delayed.clone());
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let selected = selection(&g.f.app);
|
||||
let from = node_point(&g.f.app, g.an);
|
||||
let to = from + Vector::new(400., 0.);
|
||||
let mut state = CanvasState::default();
|
||||
let press = begin(&mut g.f.app, &mut state, from);
|
||||
assert_eq!(press.mode, Mode::Arrange);
|
||||
move_pointer(&mut g.f.app, &mut state, to);
|
||||
assert!(g.f.app.allocation_drag.preview.is_some());
|
||||
if !escape {
|
||||
assert_eq!(g.f.app.route_context(), Some(old));
|
||||
assert_ne!(g.f.app.selected, edge);
|
||||
}
|
||||
assert_route_noop(&mut g.f.app, delayed);
|
||||
release(&mut g.f.app, &mut state, to);
|
||||
let mut expected = before.clone();
|
||||
let diagram = expected.diagrams.get_mut(&g.f.diagram).unwrap();
|
||||
diagram.nodes.get_mut(&g.an).unwrap().position = xy(500., 100.);
|
||||
diagram.nodes.get_mut(&g.f.node).unwrap().position = xy(540., 200.);
|
||||
diagram.nodes.get_mut(&g.yn).unwrap().position = xy(540., 600.);
|
||||
// Both endpoint placements move by (400, 0); the saved pin follows once,
|
||||
// while label offset and endpoint attachment settings stay unchanged.
|
||||
diagram
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|r| r.edge == edge)
|
||||
.unwrap()
|
||||
.waypoints = vec![xy(1100., 400.)];
|
||||
assert_eq!(selection(&g.f.app), selected);
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_route_commit_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_route_attachment_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_context_route_commit_after_selection_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_context_route_attachment_after_selection_preserves_arrange_arm_and_live_group() {
|
||||
stale_route_preserves_new_group(true, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_route_context_fields_independently_reject_before_retirement() {
|
||||
for attachment in [false, true] {
|
||||
let (mut g, edge) = routed_group();
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
// The selected edge and route payload are valid. Each capture mismatch
|
||||
// is isolated; no nonexistent route session can mask admission.
|
||||
for captured in [
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
diagram: Id::new_v4(),
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
revision: current.revision + 1,
|
||||
..current
|
||||
},
|
||||
Context {
|
||||
epoch: current.epoch + 1,
|
||||
..current
|
||||
},
|
||||
] {
|
||||
let event = packet(&g, edge, captured, attachment);
|
||||
assert_route_noop(&mut g.f.app, event);
|
||||
}
|
||||
// Hold the captured packet fixed and advance the live context instead.
|
||||
let event = packet(&g, edge, current, attachment);
|
||||
g.f.app.canvas_epoch += 1;
|
||||
assert_route_noop(&mut g.f.app, event.clone());
|
||||
g.f.app.canvas_epoch = current.epoch;
|
||||
g.f.app.active_diagram = None;
|
||||
assert_route_noop(&mut g.f.app, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_route_commits_without_session_preserve_exact_route_history() {
|
||||
for attachment in [false, true] {
|
||||
let (mut g, edge) = routed_group();
|
||||
let current = g.f.app.route_context().unwrap();
|
||||
let event = packet(&g, edge, current, attachment);
|
||||
let before = g.f.app.project().clone();
|
||||
let source = g.f.app.document.bundle().clone();
|
||||
let revision = g.f.app.document.revision();
|
||||
assert!(g.f.app.connection.is_none());
|
||||
assert!(g.f.app.selected_attachment.is_none());
|
||||
let _ = g.f.app.update(Message::Route(event));
|
||||
assert_eq!(g.f.app.document.revision(), revision + 1);
|
||||
assert_eq!(g.f.app.selected, edge);
|
||||
assert_eq!(
|
||||
g.f.app.selected_bend,
|
||||
if attachment { None } else { Some((edge, 0)) }
|
||||
);
|
||||
let mut expected = before.clone();
|
||||
let route = expected
|
||||
.diagrams
|
||||
.get_mut(&g.f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|r| r.edge == edge)
|
||||
.unwrap();
|
||||
if attachment {
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
});
|
||||
} else {
|
||||
route.waypoints = vec![xy(750., 450.)];
|
||||
}
|
||||
exact_history(&mut g.f.app, &before, &source, &expected);
|
||||
}
|
||||
}
|
||||
@@ -27,30 +27,11 @@ pub enum Event {
|
||||
Cancel,
|
||||
}
|
||||
|
||||
pub fn subscription(app: &App) -> Subscription<Message> {
|
||||
if app.architecture.as_ref().is_some_and(State::inline) {
|
||||
iced::event::listen_with(captured_escape)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_escape(
|
||||
event: iced::Event,
|
||||
status: iced::event::Status,
|
||||
_: iced::window::Id,
|
||||
) -> Option<Message> {
|
||||
// A focused Iced text input captures Escape while dropping its focus. Only
|
||||
// inline architecture naming opts into also cancelling that same keypress.
|
||||
(status == iced::event::Status::Captured
|
||||
&& matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(Message::Architecture(Event::CapturedEscape))
|
||||
pub(super) fn captured_escape_message(app: &App) -> Option<Message> {
|
||||
app.architecture
|
||||
.as_ref()
|
||||
.is_some_and(State::inline)
|
||||
.then_some(Message::Architecture(Event::CapturedEscape))
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -138,7 +119,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
.diagrams
|
||||
.get(&s.diagram)
|
||||
.ok_or("The creation draft's diagram is missing.")?;
|
||||
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|
||||
if !d.projection.is_architecture()
|
||||
|| s.query.len() > 16384
|
||||
|| s.page > 1_000_000
|
||||
|| s.point
|
||||
@@ -180,7 +161,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
}
|
||||
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
|
||||
let mut values = match (d.layer, d.projection) {
|
||||
(_, ProjectionKind::StateMachine { .. }) => vec![],
|
||||
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
|
||||
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
|
||||
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
|
||||
(Layer::Oa, ProjectionKind::Capabilities) => vec![
|
||||
@@ -208,11 +189,12 @@ pub fn kinds(app: &App) -> Vec<Kind> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
d.projection.is_architecture()
|
||||
&& p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
}
|
||||
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
|
||||
// Existing System remains a useful choice even when the singleton exists.
|
||||
@@ -257,6 +239,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
|
||||
) && !self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection.is_architecture())
|
||||
{
|
||||
self.status = "Choose an architecture view before adding architecture content.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
|
||||
if self.architecture.is_some() {
|
||||
@@ -301,6 +294,17 @@ impl App {
|
||||
_ => return Task::none(),
|
||||
};
|
||||
let placing = point.is_none() && matches!(intent, Intent::Create { .. });
|
||||
let filter = if matches!(intent, Intent::Existing { .. })
|
||||
&& self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| d.projection == ProjectionKind::Traceability)
|
||||
{
|
||||
Filter::AllCompatible
|
||||
} else {
|
||||
Filter::Context
|
||||
};
|
||||
let saved = Saved {
|
||||
version: 1,
|
||||
base: oa_core::transitions::project_fingerprint(self.project()),
|
||||
@@ -309,7 +313,7 @@ impl App {
|
||||
point,
|
||||
placing,
|
||||
query: String::new(),
|
||||
filter: Filter::Context,
|
||||
filter,
|
||||
show_placed: false,
|
||||
page: 0,
|
||||
};
|
||||
|
||||
@@ -229,13 +229,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
content = content.push(group("Connections"));
|
||||
for connection in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
let d = &app.project().diagrams[&app.active_diagram.unwrap()];
|
||||
let kind = match connection {
|
||||
crate::connection_tool::Tool::Interaction => Kind::Activity,
|
||||
crate::connection_tool::Tool::FunctionalExchange => Kind::Function,
|
||||
crate::connection_tool::Tool::CommunicationMean => Kind::Entity,
|
||||
crate::connection_tool::Tool::BoundaryExchange => Kind::System,
|
||||
};
|
||||
if !d.projection.includes(kind) {
|
||||
if !connection.available_in(app.layer, d.projection) {
|
||||
continue;
|
||||
}
|
||||
content = content.push(tool(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
mod traceability;
|
||||
|
||||
fn setup(layer: Layer) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
@@ -233,65 +234,6 @@ fn architecture_stale_submit_keeps_create_and_rename_visible_and_exact() {
|
||||
}
|
||||
}
|
||||
|
||||
fn key_event(named: iced::keyboard::key::Named) -> iced::Event {
|
||||
use iced::keyboard;
|
||||
iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(named),
|
||||
modified_key: keyboard::Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn focused_text_input_escape(name: &str) -> Message {
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
let mut field: iced::Element<'_, (), iced::Theme, ()> = text_input("Engineering name", name)
|
||||
.id("architecture-name")
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(field.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
field
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let escape = key_event(iced::keyboard::key::Named::Escape);
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
field.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&escape,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"Iced consumes focused-input Escape"
|
||||
);
|
||||
assert!(
|
||||
!tree
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
);
|
||||
captured_escape(
|
||||
escape,
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_first_focused_input_escape_cancels_create_and_rename() {
|
||||
for rename in [false, true] {
|
||||
@@ -311,8 +253,9 @@ fn architecture_first_focused_input_escape_cancels_create_and_rename() {
|
||||
let name = " Unaccepted long engineering name 雨 ";
|
||||
send(&mut app, Event::Name(name.into()));
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
let message = focused_text_input_escape(name);
|
||||
let _ = app.update(message);
|
||||
let mut messages = crate::app::escape::tests::focused_input_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let _ = app.update(messages.remove(0));
|
||||
assert!(app.architecture.is_none());
|
||||
assert!(inline_editor(&app).is_none());
|
||||
assert_eq!(app.status, "Cancelled · Select / move");
|
||||
@@ -342,8 +285,8 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
|
||||
let before = app.document.bundle().clone();
|
||||
let status = app.status.clone();
|
||||
let epoch = app.canvas_epoch;
|
||||
let message = focused_text_input_escape("Other field or existing-element search");
|
||||
let _ = app.update(message);
|
||||
let messages = crate::app::escape::tests::focused_input_messages(&app);
|
||||
assert!(messages.is_empty());
|
||||
assert_eq!(
|
||||
app.architecture
|
||||
.as_ref()
|
||||
@@ -355,19 +298,7 @@ fn architecture_captured_escape_does_not_cancel_other_controls_or_placement() {
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
assert!(
|
||||
captured_escape(
|
||||
key_event(iced::keyboard::key::Named::Escape),
|
||||
iced::event::Status::Ignored,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
captured_escape(
|
||||
key_event(iced::keyboard::key::Named::Enter),
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.is_none()
|
||||
crate::app::escape::tests::focused_input_key(&app, iced::keyboard::key::Named::Enter,)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn traceability_existing_defaults_show_opposite_layer_and_place_only_one_node() {
|
||||
let mut project = Project::empty();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Deliver report", None)
|
||||
.unwrap();
|
||||
let system = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Event::Existing(None));
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.filter,
|
||||
Filter::AllCompatible
|
||||
);
|
||||
let listed = candidates(&app);
|
||||
assert!(listed.iter().any(|(id, _, shown)| *id == sa && !shown));
|
||||
assert!(listed.iter().any(|(id, _, shown)| *id == system && !shown));
|
||||
send(&mut app, Event::Pick(sa));
|
||||
assert!(app.architecture.as_ref().unwrap().saved.placing);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
let context = app.route_context().unwrap();
|
||||
let point = Position { x: 345., y: 215. };
|
||||
send(&mut app, Event::Click(context, point));
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
let placed = app.project().diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == sa)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(placed.position, point);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(placed.id, placed);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_default_does_not_change_creation_or_ordinary_picker_guards() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
let diagram = app.active_diagram.unwrap();
|
||||
send(&mut app, Event::Existing(None));
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.filter,
|
||||
Filter::Context
|
||||
);
|
||||
send(&mut app, Event::Cancel);
|
||||
let _ = app.update(Message::Projection(ProjectionKind::Traceability));
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
assert_eq!(
|
||||
app.architecture.as_ref().unwrap().saved.filter,
|
||||
Filter::Context
|
||||
);
|
||||
send(&mut app, Event::Cancel);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
|
||||
let mut project = app.project().clone();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Other layer", None)
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
project.elements.insert(
|
||||
port,
|
||||
oa_core::Element {
|
||||
id: port,
|
||||
alias: "legacy_port".into(),
|
||||
name: "Port".into(),
|
||||
description: String::new(),
|
||||
parent: Some(sa),
|
||||
body: ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
for projection in [ProjectionKind::Overview, ProjectionKind::Traceability] {
|
||||
let mut p = project.clone();
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = projection;
|
||||
let mut trial = App::with_project(p);
|
||||
trial.page = Page::Diagram;
|
||||
trial.active_diagram = Some(diagram);
|
||||
trial.layer = Layer::Oa;
|
||||
let before = trial.document.bundle().clone();
|
||||
send(&mut trial, Event::Existing(None));
|
||||
send(&mut trial, Event::Filter(Filter::AllCompatible));
|
||||
assert_eq!(
|
||||
candidates(&trial).iter().any(|(id, _, _)| *id == sa),
|
||||
projection == ProjectionKind::Traceability
|
||||
);
|
||||
assert!(!candidates(&trial).iter().any(|(id, _, _)| *id == port));
|
||||
send(&mut trial, Event::Pick(port));
|
||||
assert!(!trial.architecture.as_ref().unwrap().saved.placing);
|
||||
send(&mut trial, Event::Cancel);
|
||||
assert_eq!(trial.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
@@ -180,10 +180,36 @@ async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert!(app.delete_pending, "old token does not cancel new dialog");
|
||||
// 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!(
|
||||
app.delete_target.as_ref().unwrap().behavior_owner,
|
||||
Some(owners[0])
|
||||
);
|
||||
app.behavior.active_owner = Some(owners[1]);
|
||||
app.reconcile_scenario_workspace();
|
||||
let other = app.scenario_context().unwrap();
|
||||
assert_eq!(other.scenario, owners[1]);
|
||||
assert_eq!(
|
||||
other.representation,
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner: owners[1] }
|
||||
);
|
||||
assert_eq!(other.epoch, captured.epoch);
|
||||
assert_eq!(app.selected, uses[0]);
|
||||
assert!(
|
||||
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
|
||||
"the independent owner guard must reject an otherwise current token"
|
||||
);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(!app.delete_pending && app.delete_target.is_none());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
app.behavior.active_owner = Some(owners[0]);
|
||||
// Restore both owner and qualified presentation before mounting pointer input.
|
||||
// Passive reports from the previous owner cannot repair an inconsistent fixture.
|
||||
app.reconcile_scenario_workspace();
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, owners[0]);
|
||||
assert_eq!(app.selected, uses[0]);
|
||||
mounted.settle(&mut app).await;
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Complete a navigation camera after the destination reports its real viewport.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::{Context, Surface};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -47,11 +48,23 @@ impl App {
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
|
||||
Surface::Scenario(c.scenario),
|
||||
match c.representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch,
|
||||
&& c.epoch == *epoch
|
||||
&& self.pending_scenario_reveal_visit == Some(c.visit)
|
||||
&& self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(c.scenario)
|
||||
&& self.behavior.scenario.context(
|
||||
self.canvas_document,
|
||||
c.scenario,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
) == Some(*c),
|
||||
),
|
||||
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
|
||||
Surface::Saved(c.diagram),
|
||||
@@ -93,6 +106,7 @@ impl App {
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
) {
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
if !Self::is_navigation_camera(message) {
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
@@ -142,5 +156,6 @@ impl App {
|
||||
self.reveal_diagram_usage(&usage);
|
||||
}
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,7 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
@@ -143,6 +144,7 @@ fn measurement(
|
||||
epoch: u64,
|
||||
surface: Surface,
|
||||
size: Size,
|
||||
visit: Id,
|
||||
) -> Message {
|
||||
match canvas {
|
||||
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
|
||||
@@ -159,6 +161,10 @@ fn measurement(
|
||||
behavior::scenario::Context {
|
||||
document: context.document,
|
||||
scenario: surface.id(),
|
||||
representation: Representation::Derived {
|
||||
owner: surface.id(),
|
||||
},
|
||||
visit,
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
@@ -176,12 +182,16 @@ fn measurement(
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(app: &App) -> (Context, u64) {
|
||||
fn stamp(app: &App) -> (Context, u64, Id) {
|
||||
let (context, _, epoch) = app
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.expect("reveal awaits measured destination");
|
||||
(*context, *epoch)
|
||||
(
|
||||
*context,
|
||||
*epoch,
|
||||
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
@@ -219,7 +229,11 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
expected.camera(
|
||||
app.project(),
|
||||
target.surface.id(),
|
||||
behavior::canvas_workspace::Event::Pan(pan),
|
||||
);
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
}
|
||||
@@ -238,10 +252,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
app.document.dirty(),
|
||||
);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -260,7 +274,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.camera_boundary() == camera);
|
||||
@@ -274,7 +288,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
for invalid in 0..6 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let camera = app.camera_boundary();
|
||||
let mut stale = context;
|
||||
let mut wrong_epoch = epoch;
|
||||
@@ -289,13 +303,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size),
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
|
||||
);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_some(),
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
assert_eq!(stamp(&app), (context, epoch));
|
||||
assert_eq!(stamp(&app), (context, epoch, visit));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
@@ -303,7 +317,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -317,9 +331,10 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, old_epoch) = stamp(&app);
|
||||
let (context, old_epoch, visit) = stamp(&app);
|
||||
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
|
||||
let (current, epoch) = stamp(&app);
|
||||
let (current, epoch, current_visit) = stamp(&app);
|
||||
assert_eq!(current_visit, visit);
|
||||
assert_eq!(current, context);
|
||||
assert_eq!(epoch, app.canvas_epoch);
|
||||
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
|
||||
@@ -327,14 +342,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert_eq!(stamp(&app), (current, epoch));
|
||||
assert_eq!(stamp(&app), (current, epoch, visit));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL),
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -349,7 +364,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch) = stamp(&app);
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let event = match action {
|
||||
0 => match canvas {
|
||||
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
|
||||
@@ -386,7 +401,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
// restore the old navigation camera after deliberate user input.
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, WIDE),
|
||||
measurement(canvas, context, epoch, target.surface, WIDE, visit),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Navigation-only adapter: validates destinations before changing presentation.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -181,6 +182,7 @@ impl App {
|
||||
// Only an accepted new chooser supersedes the previous reveal.
|
||||
// Rejected/stale panel envelopes leave its first measurement intact.
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::batch([
|
||||
@@ -291,11 +293,78 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
|
||||
/// never selects the owner or another saved arrangement as a fallback.
|
||||
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
|
||||
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(found) = self
|
||||
.current_surface_usages(selected, Surface::Saved(diagram))
|
||||
.into_iter()
|
||||
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
|
||||
else {
|
||||
self.status = "This object has no visible target in that saved Scenario.".into();
|
||||
return Task::none();
|
||||
};
|
||||
self.reveal_diagram_usage(&found);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let scenario_representation = match usage.surface {
|
||||
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
|
||||
Some(Representation::Saved { diagram })
|
||||
}
|
||||
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(representation) = scenario_representation {
|
||||
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
|
||||
return false;
|
||||
};
|
||||
if !self
|
||||
.behavior
|
||||
.scenario
|
||||
.representation_usages(self.project(), representation, usage.subject)
|
||||
.iter()
|
||||
.any(|row| row == usage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Finish target/camera admission before changing the App route. The
|
||||
// checked immutable owner also makes the following entry infallible.
|
||||
let revealed = match representation {
|
||||
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
),
|
||||
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
representation,
|
||||
&usage.target,
|
||||
),
|
||||
};
|
||||
if !revealed {
|
||||
return false;
|
||||
}
|
||||
if !self.enter_scenario_representation(representation) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
Target::Whole => (usage.surface.id(), None),
|
||||
@@ -328,7 +397,10 @@ impl App {
|
||||
};
|
||||
self.layer = diagram.layer;
|
||||
self.active_diagram = Some(id);
|
||||
if let Some(machine) = diagram.projection.state_machine() {
|
||||
if let Some(owner) = diagram.projection.scenario() {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
} else if let Some(machine) = diagram.projection.state_machine() {
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
@@ -370,19 +442,14 @@ impl App {
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
if !self.behavior.scenario.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
}
|
||||
if scenario_representation.is_none() {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
self.selected = selected;
|
||||
self.selected_node = placement;
|
||||
self.model_menu = None;
|
||||
@@ -409,6 +476,18 @@ impl App {
|
||||
usage: usage.clone(),
|
||||
selected,
|
||||
});
|
||||
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
|
||||
let owner = presentation::checked_owner(self.project(), representation)?;
|
||||
self.behavior
|
||||
.scenario
|
||||
.context(
|
||||
self.canvas_document,
|
||||
owner,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
)
|
||||
.map(|context| context.visit)
|
||||
});
|
||||
self.pending_diagram_reveal = Some((
|
||||
Context {
|
||||
document: self.canvas_document,
|
||||
@@ -429,10 +508,20 @@ impl App {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
&& (!self.saved_scenario_diagram(id)
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Saved { diagram: id })))
|
||||
}
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
Surface::Process(owner) => {
|
||||
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(owner)
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Derived { owner })
|
||||
}
|
||||
};
|
||||
(current_surface
|
||||
&& focus.usage.surface == surface
|
||||
|
||||
@@ -1451,3 +1451,5 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
mod saved_scenario;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
arcadia 7;
|
||||
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
|
||||
// Two operational activities, with one lifeline appearance per Scenario.
|
||||
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
|
||||
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
|
||||
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
|
||||
source @report_incident;
|
||||
target @assess_incident;
|
||||
}
|
||||
|
||||
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
|
||||
}
|
||||
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
|
||||
parent @incident_response; represented @report_incident;
|
||||
}
|
||||
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
|
||||
parent @incident_response; represented @assess_incident;
|
||||
}
|
||||
// Both appearances reuse one definition. Navigation must retain the occurrence.
|
||||
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
// A correlated self call/reply with deliberately unassigned exchange definitions.
|
||||
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
|
||||
}
|
||||
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
|
||||
}
|
||||
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
|
||||
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
|
||||
}
|
||||
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
|
||||
parent @incident_response;
|
||||
body { ScenarioUse {
|
||||
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
|
||||
scenario @reporting_review;
|
||||
}; };
|
||||
}
|
||||
|
||||
// This definition stays opaque in the caller. Open it, then Back to the saved view.
|
||||
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@review_delivery];
|
||||
}
|
||||
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
|
||||
parent @reporting_review; represented @report_incident;
|
||||
}
|
||||
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
|
||||
parent @reporting_review; represented @assess_incident;
|
||||
}
|
||||
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
|
||||
parent @reporting_review;
|
||||
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
|
||||
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
|
||||
messages [
|
||||
{ address { references []; occurrence @initial_report; }; send_y 160; },
|
||||
{ address { references []; occurrence @updated_report; }; send_y 252; },
|
||||
{ address { references []; occurrence @assess_call; }; send_y 344; },
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 436; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
|
||||
messages [
|
||||
{ address { references []; occurrence @initial_report; }; send_y 160; },
|
||||
{ address { references []; occurrence @updated_report; }; send_y 252; },
|
||||
{ address { references []; occurrence @assess_call; }; send_y 344; },
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 436; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
// These valid authored lanes must remain saved during complete fallback.
|
||||
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
|
||||
messages [
|
||||
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 300; },
|
||||
// This authored value stays latent while the receiver is missing.
|
||||
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, State};
|
||||
use crate::behavior::scenario_layout::{
|
||||
Representation,
|
||||
presentation::{self, PresentationStatus},
|
||||
};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
fn fixture() -> App {
|
||||
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
|
||||
let errors: Vec<_> = oa_core::validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|d| d.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
App::with_project(p)
|
||||
}
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn representation(view: Id) -> Representation {
|
||||
Representation::Saved { diagram: view }
|
||||
}
|
||||
fn find(app: &App, subject: Id, view: Id) -> Usage {
|
||||
usage::saved_usages_for(app.project(), subject, Some(view))
|
||||
.into_iter()
|
||||
.find(|row| row.target == Target::ScenarioObject(subject))
|
||||
.expect("exact saved occurrence")
|
||||
}
|
||||
fn context(app: &App) -> behavior::scenario::Context {
|
||||
let owner = app.behavior.active_owner.unwrap();
|
||||
app.behavior
|
||||
.scenario
|
||||
.context(
|
||||
app.canvas_document,
|
||||
owner,
|
||||
app.document.revision(),
|
||||
app.canvas_epoch,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
|
||||
let (token, rows) = open(app, subject);
|
||||
let found = rows
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
|
||||
.expect("saved chooser destination");
|
||||
choose(app, token, found);
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(representation(view))
|
||||
);
|
||||
assert_eq!(app.selected, subject);
|
||||
token
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
|
||||
{
|
||||
let app = fixture();
|
||||
let owner = id(&app, "incident_response");
|
||||
let first = id(&app, "initial_report");
|
||||
let second = id(&app, "updated_report");
|
||||
let exchange = id(&app, "incident_report");
|
||||
let latent = id(&app, "unfinished_notice");
|
||||
let callee_message = id(&app, "review_delivery");
|
||||
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
|
||||
let derived = app
|
||||
.behavior
|
||||
.scenario
|
||||
.diagram_usages(app.project(), owner, first)
|
||||
.pop()
|
||||
.unwrap();
|
||||
let rows = views.map(|view| find(&app, first, view));
|
||||
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
|
||||
assert_ne!(rows[0].bounds, rows[1].bounds);
|
||||
assert_eq!(
|
||||
rows[2].bounds, derived.bounds,
|
||||
"complete fallback cannot retain the authored lane override"
|
||||
);
|
||||
for view in views {
|
||||
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
|
||||
assert_eq!(represented.len(), 2);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.any(|u| u.target == Target::ScenarioObject(first))
|
||||
);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.any(|u| u.target == Target::ScenarioObject(second))
|
||||
);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.all(|u| u.surface == Surface::Saved(view))
|
||||
);
|
||||
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
|
||||
assert_eq!(
|
||||
find(&app, latent, view).target,
|
||||
Target::ScenarioObject(latent)
|
||||
);
|
||||
let shown = presentation::present(app.project(), representation(view), owner);
|
||||
let whole = usage::saved_usages_for(app.project(), view, Some(view));
|
||||
assert_eq!(whole.len(), 1);
|
||||
assert_eq!(whole[0].target, Target::Whole);
|
||||
assert_eq!(Some(whole[0].bounds), shown.bounds());
|
||||
}
|
||||
let wide = presentation::present(app.project(), representation(views[1]), owner)
|
||||
.bounds()
|
||||
.unwrap();
|
||||
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
|
||||
let fallback = presentation::present(app.project(), representation(views[2]), owner);
|
||||
assert!(matches!(
|
||||
fallback.status(),
|
||||
PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert_eq!(fallback.latent_messages(), &[latent]);
|
||||
assert!(!fallback.capabilities().coordinate_input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
|
||||
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
|
||||
let mut app = fixture();
|
||||
let view = id(&app, alias);
|
||||
let first = id(&app, "initial_report");
|
||||
let owner = id(&app, "incident_response");
|
||||
let original = app.document.snapshot();
|
||||
app.document
|
||||
.execute(Command::Rename(first, "Reviewed incident report".into()))
|
||||
.unwrap();
|
||||
let edited = app.document.snapshot();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let before = authored(&app);
|
||||
navigate(&mut app, first, view);
|
||||
assert_eq!(app.behavior.active_owner, Some(owner));
|
||||
assert_eq!(authored(&app), before);
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
assert_eq!(authored(&app), before);
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
|
||||
for chooser in [false, true] {
|
||||
for block in 0..3 {
|
||||
let mut app = fixture();
|
||||
let view = id(&app, "incident_repair");
|
||||
let first = id(&app, "initial_report");
|
||||
let choice = chooser.then(|| {
|
||||
let (token, rows) = open(&mut app, first);
|
||||
(
|
||||
token,
|
||||
rows.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view))
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
match block {
|
||||
0 => {
|
||||
let cursor = app.source_editor.cursor();
|
||||
app.source_workspace
|
||||
.edit("? preserve unfinished source".into(), cursor);
|
||||
}
|
||||
1 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied inspector value".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
_ => {
|
||||
let p = app.project().clone();
|
||||
app.behavior
|
||||
.creation
|
||||
.update(&p, behavior::creation::Event::Open(Layer::Oa))
|
||||
.unwrap();
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
}
|
||||
let before = authored(&app);
|
||||
let prior = location(&app);
|
||||
let draft = app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
if let Some((token, row)) = choice {
|
||||
choose(&mut app, token, row);
|
||||
} else {
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
}
|
||||
assert_eq!(authored(&app), before);
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
|
||||
let mut app = fixture();
|
||||
let a = id(&app, "incident_readable");
|
||||
let b = id(&app, "incident_wide");
|
||||
let first = id(&app, "initial_report");
|
||||
let before = authored(&app);
|
||||
let old_token = navigate(&mut app, first, a);
|
||||
let old = context(&app);
|
||||
navigate(&mut app, first, b);
|
||||
navigate(&mut app, first, a);
|
||||
let current = context(&app);
|
||||
assert_ne!(old.visit, current.visit);
|
||||
let held = behavior::scenario::Context {
|
||||
epoch: current.epoch,
|
||||
..old
|
||||
};
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(
|
||||
held,
|
||||
crate::behavior::scenario::Shelf::Selection,
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
|
||||
);
|
||||
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
|
||||
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
let bounds = find(&app, first, a).bounds;
|
||||
send(
|
||||
&mut app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(context(&app).visit, current.visit);
|
||||
let mut expected = State::default();
|
||||
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
|
||||
expected.viewport(Size::new(500., 300.));
|
||||
expected.zoom(Some(1.25));
|
||||
expected.camera(
|
||||
app.project(),
|
||||
current.scenario,
|
||||
behavior::canvas_workspace::Event::Pan(Vector::new(
|
||||
250. - bounds.center_x() * 1.25,
|
||||
150. - bounds.center_y() * 1.25,
|
||||
)),
|
||||
);
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
|
||||
let mut app = fixture();
|
||||
let first = id(&app, "initial_report");
|
||||
let view = id(&app, "incident_readable");
|
||||
let other = id(&app, "reporting_review");
|
||||
let (token, rows) = open(&mut app, first);
|
||||
let old = rows
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view))
|
||||
.unwrap();
|
||||
let mut replacement = app.project().diagrams[&view].clone();
|
||||
replacement.projection = ProjectionKind::Scenario { scenario: other };
|
||||
replacement.layout = None;
|
||||
app.document
|
||||
.execute(Command::EditDiagram(view, replacement))
|
||||
.unwrap();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let before = authored(&app);
|
||||
let prior = location(&app);
|
||||
choose(&mut app, token, old);
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
finish_lookup(&mut app);
|
||||
let token = app.diagram_usages.as_ref().unwrap().token;
|
||||
assert!(
|
||||
app.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.all(|u| u.surface != Surface::Saved(view))
|
||||
);
|
||||
send(&mut app, Message::DiagramUsage(Event::Close(token)));
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
|
||||
{
|
||||
let mut app = fixture();
|
||||
let view = id(&app, "incident_repair");
|
||||
let first = id(&app, "initial_report");
|
||||
let reply = id(&app, "assess_reply");
|
||||
let before = app.document.snapshot();
|
||||
let fallback = find(&app, first, view);
|
||||
app.document
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: view,
|
||||
position: oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: reply,
|
||||
},
|
||||
send_y: 424.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let repaired = app.document.snapshot();
|
||||
let authored_row = find(&app, first, view);
|
||||
assert_ne!(authored_row.bounds, fallback.bounds);
|
||||
navigate(&mut app, first, view);
|
||||
assert!(app.document.undo());
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
|
||||
assert!(app.document.redo());
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
assert_eq!(app.project(), &repaired.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &repaired.bundle);
|
||||
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//! 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
|
||||
//! current-overlay preflight also handles pickers opened mid-batch. Passive
|
||||
//! overlays may see an ignored Escape again: the raw-input observer only makes
|
||||
//! pending focus requests stale; it does not publish modal cancellation.
|
||||
use super::{App, Message, Page, architecture_authoring};
|
||||
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
|
||||
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
|
||||
|
||||
pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
|
||||
app: &App,
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
) -> Element<'a, Message, Theme, R> {
|
||||
let modal = app.text_workspace.menu().is_none()
|
||||
&& (app.behavior.creation.has_pending()
|
||||
|| app.diagram_usages.is_some()
|
||||
|| app.behavior.source_active()
|
||||
|| (app.page == Page::Text
|
||||
&& (app.text_workspace.drawer().is_some()
|
||||
|| app.source_navigation.open
|
||||
|| app.source_rename.open())
|
||||
&& app.architecture.is_none()));
|
||||
Element::new(Bridge {
|
||||
content,
|
||||
messages: [
|
||||
architecture_authoring::captured_escape_message(app),
|
||||
modal.then_some(Message::Escape),
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
struct Bridge<'a, R> {
|
||||
content: Element<'a, Message, Theme, R>,
|
||||
messages: [Option<Message>; 2],
|
||||
}
|
||||
|
||||
impl<R: renderer::Renderer> Widget<Message, Theme, R> for Bridge<'_, R> {
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
vec![widget::Tree::new(self.content.as_widget())]
|
||||
}
|
||||
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
|
||||
fn size_hint(&self) -> Size<Length> {
|
||||
self.content.as_widget().size_hint()
|
||||
}
|
||||
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
renderer: &R,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &R,
|
||||
operation: &mut dyn widget::Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut widget::Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &R,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let escape = matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
// Iced collects overlays before processing a batch of base events. A
|
||||
// preceding click in that batch can therefore open a picker whose
|
||||
// Escape would otherwise reach focused fields underneath it. Offer only
|
||||
// Escape to the current overlay before updating the ordinary tree.
|
||||
if escape {
|
||||
if let Some(element) = self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
Vector::ZERO,
|
||||
) {
|
||||
let mut overlay = overlay::Nested::new(element);
|
||||
let node = overlay.layout(renderer, viewport.size());
|
||||
overlay.update(
|
||||
event,
|
||||
Layout::new(&node),
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
);
|
||||
}
|
||||
if shell.is_event_captured() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
if shell.is_event_captured() && escape {
|
||||
for message in self.messages.iter().flatten() {
|
||||
shell.publish(message.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
renderer: &mut R,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &widget::Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &R,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut widget::Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &R,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, R>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
@@ -0,0 +1,618 @@
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::key;
|
||||
use iced::advanced::{subscription, widget::operation};
|
||||
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
|
||||
use iced::keyboard::key::Named;
|
||||
use iced::widget::{column, container, pick_list, text_input};
|
||||
use iced_runtime::user_interface::{Cache, UserInterface};
|
||||
use oa_core::{Layer, Project};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
struct FocusInputs;
|
||||
impl widget::Operation for FocusInputs {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
state.focus();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn subscriptions(
|
||||
app: &App,
|
||||
event: Event,
|
||||
status: iced::event::Status,
|
||||
messages: &mut Vec<Message>,
|
||||
) {
|
||||
let interaction = subscription::Event::Interaction {
|
||||
window: iced::window::Id::unique(),
|
||||
event,
|
||||
status,
|
||||
};
|
||||
let mut boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
|
||||
let mut cx = Context::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
|
||||
if matches!(
|
||||
message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
boundaries, 1,
|
||||
"one passive allocation keyboard notification"
|
||||
);
|
||||
}
|
||||
|
||||
/// A real focused text input, production root bridge, UserInterface dispatch,
|
||||
/// and actual subscriptions. Existing app-policy tests use this instead of
|
||||
/// pretending every captured event came from a text control.
|
||||
pub(crate) fn focused_input_messages(app: &App) -> Vec<Message> {
|
||||
focused_input_key(app, Named::Escape)
|
||||
}
|
||||
|
||||
pub(crate) fn focused_input_key(app: &App, named: Named) -> Vec<Message> {
|
||||
input_messages(app, named, true)
|
||||
}
|
||||
|
||||
pub(crate) fn input_messages(app: &App, named: Named, focused: bool) -> Vec<Message> {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let input: Element<'_, Message, Theme, ()> = text_input("Retained input", "Uncommitted 雨")
|
||||
.on_input(|_| Message::Escape)
|
||||
.into();
|
||||
let mut renderer = ();
|
||||
let mut ui = UserInterface::build(
|
||||
root(app, input),
|
||||
Size::new(400., 100.),
|
||||
Cache::new(),
|
||||
&mut renderer,
|
||||
);
|
||||
if focused {
|
||||
ui.operate(&renderer, &mut FocusInputs);
|
||||
}
|
||||
let event = key(named);
|
||||
let mut messages = vec![];
|
||||
let (_, statuses) = ui.update(
|
||||
std::slice::from_ref(&event),
|
||||
mouse::Cursor::Unavailable,
|
||||
&mut renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
if named == Named::Escape {
|
||||
assert_eq!(
|
||||
statuses,
|
||||
[if focused {
|
||||
iced::event::Status::Captured
|
||||
} else {
|
||||
iced::event::Status::Ignored
|
||||
}]
|
||||
);
|
||||
}
|
||||
subscriptions(app, event, statuses[0], &mut messages);
|
||||
messages
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum PickMessage {
|
||||
Selected(&'static str),
|
||||
Opened,
|
||||
Closed,
|
||||
}
|
||||
|
||||
fn picker(
|
||||
selected: Option<&'static str>,
|
||||
notify: bool,
|
||||
) -> Element<'static, PickMessage, Theme, ()> {
|
||||
let list = pick_list(["Alpha", "Beta"], selected, PickMessage::Selected)
|
||||
.width(200)
|
||||
.padding(10)
|
||||
.text_size(16)
|
||||
.on_open(PickMessage::Opened);
|
||||
if notify {
|
||||
list.on_close(PickMessage::Closed).into()
|
||||
} else {
|
||||
list.into()
|
||||
}
|
||||
}
|
||||
|
||||
fn update<M>(
|
||||
ui: &mut UserInterface<'_, M, Theme, ()>,
|
||||
events: &[Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> (Vec<M>, Vec<iced::event::Status>) {
|
||||
let mut messages = vec![];
|
||||
let (_, statuses) = ui.update(
|
||||
events,
|
||||
cursor,
|
||||
&mut (),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
);
|
||||
(messages, statuses)
|
||||
}
|
||||
|
||||
fn press_at(point: iced::Point) -> (Vec<Event>, mouse::Cursor) {
|
||||
(
|
||||
vec![
|
||||
Event::Mouse(mouse::Event::CursorMoved { position: point }),
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_overlay_escape_closes_once_reopens_selects_and_retains_outside_click() {
|
||||
for selected in [None, Some("Alpha")] {
|
||||
for notify in [false, true] {
|
||||
let mut ui = UserInterface::build(
|
||||
picker(selected, notify),
|
||||
Size::new(400., 300.),
|
||||
Cache::new(),
|
||||
&mut (),
|
||||
);
|
||||
let (click, cursor) = press_at(iced::Point::new(50., 15.));
|
||||
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(selected, notify),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
let (messages, statuses) = update(
|
||||
&mut ui,
|
||||
&[key(Named::Escape), key(Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
assert_eq!(
|
||||
messages,
|
||||
if notify {
|
||||
vec![PickMessage::Closed]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
statuses,
|
||||
[iced::event::Status::Captured, iced::event::Status::Ignored]
|
||||
);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(selected, notify),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(selected, notify),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
let (outside, cursor) = press_at(iced::Point::new(350., 250.));
|
||||
assert_eq!(
|
||||
update(&mut ui, &outside, cursor).0,
|
||||
if notify {
|
||||
vec![PickMessage::Closed]
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(selected, notify),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
let (click, cursor) = press_at(iced::Point::new(50., 15.));
|
||||
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(selected, notify),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
// Default 16px text uses 1.3 line-height: button/option height 40.8.
|
||||
let (option, cursor) = press_at(iced::Point::new(50., 100.));
|
||||
assert_eq!(
|
||||
update(&mut ui, &option, cursor).0,
|
||||
[PickMessage::Selected("Beta")]
|
||||
);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(Some("Beta"), notify),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
assert_eq!(
|
||||
update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable),
|
||||
(vec![], vec![iced::event::Status::Ignored])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initially_closed_picker_handles_open_then_escape_in_one_runtime_batch() {
|
||||
for notify in [false, true] {
|
||||
let mut ui = UserInterface::build(
|
||||
picker(Some("Alpha"), notify),
|
||||
Size::new(400., 300.),
|
||||
Cache::new(),
|
||||
&mut (),
|
||||
);
|
||||
let (mut events, cursor) = press_at(iced::Point::new(50., 15.));
|
||||
events.push(key(Named::Escape));
|
||||
let (messages, statuses) = update(&mut ui, &events, cursor);
|
||||
assert_eq!(
|
||||
messages,
|
||||
if notify {
|
||||
vec![PickMessage::Opened, PickMessage::Closed]
|
||||
} else {
|
||||
vec![PickMessage::Opened]
|
||||
}
|
||||
);
|
||||
assert_eq!(statuses.last(), Some(&iced::event::Status::Captured));
|
||||
assert_eq!(
|
||||
update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable),
|
||||
(vec![], vec![iced::event::Status::Ignored])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismissed_picker_ignores_a_queued_click_in_its_old_option_area() {
|
||||
let mut ui = UserInterface::build(
|
||||
picker(Some("Alpha"), true),
|
||||
Size::new(400., 300.),
|
||||
Cache::new(),
|
||||
&mut (),
|
||||
);
|
||||
let (click, cursor) = press_at(iced::Point::new(50., 15.));
|
||||
assert_eq!(update(&mut ui, &click, cursor).0, [PickMessage::Opened]);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(
|
||||
picker(Some("Alpha"), true),
|
||||
Size::new(400., 300.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
let (click, cursor) = press_at(iced::Point::new(50., 100.));
|
||||
let mut batch = vec![key(Named::Escape)];
|
||||
batch.extend(click);
|
||||
let (messages, _) = update(&mut ui, &batch, cursor);
|
||||
assert_eq!(
|
||||
messages,
|
||||
[PickMessage::Closed],
|
||||
"queued old-option click must not select Beta"
|
||||
);
|
||||
}
|
||||
|
||||
fn draft_with_picker(app: &App) -> Element<'_, Message, Theme, ()> {
|
||||
// This composition deliberately keeps a focused input beneath the actual
|
||||
// picker overlay. It tests event ownership, not a claimed production form.
|
||||
root(
|
||||
app,
|
||||
crate::source_navigation::interaction::observe(
|
||||
column![
|
||||
container(
|
||||
text_input("Raw draft", "Keep exact bytes 雨").on_input(|_| Message::Escape)
|
||||
)
|
||||
.height(40),
|
||||
pick_list([Layer::Oa, Layer::Sa], Some(Layer::Oa), Message::Layer)
|
||||
.width(200)
|
||||
.padding(10),
|
||||
]
|
||||
.into(),
|
||||
app.source_symbols.raw_interaction.clone(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_overlay_precedes_focused_input_and_creation_source_drafts() {
|
||||
use crate::behavior::{Event as BehaviorEvent, creation, source_draft};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
for (source, same_batch) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let send = |app: &mut App, event| {
|
||||
let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event)));
|
||||
};
|
||||
send(&mut app, creation::Event::Open(Layer::Oa));
|
||||
send(&mut app, creation::Event::Choose(creation::Family::Machine));
|
||||
send(
|
||||
&mut app,
|
||||
creation::Event::Name("Retained machine 雨".into()),
|
||||
);
|
||||
if source {
|
||||
let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap();
|
||||
let invocation = serde_json::from_value(recovery["invocation"].clone()).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
creation::Event::Source(source_draft::Event::Open(invocation)),
|
||||
);
|
||||
let recovery = app.behavior.creation.recovery_snapshot().unwrap().unwrap();
|
||||
let token =
|
||||
serde_json::from_value(recovery["source"]["editing"]["token"].clone()).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
creation::Event::Source(source_draft::Event::Edit(
|
||||
token,
|
||||
source_draft::Edit::Name("Uncommitted owner 雨".into()),
|
||||
)),
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.creation.recovery_snapshot().unwrap().unwrap()["source"]["editing"]["name"],
|
||||
"Uncommitted owner 雨"
|
||||
);
|
||||
}
|
||||
let draft = app.behavior.recovery_snapshot().unwrap();
|
||||
let project = app.project().clone();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let selection = (app.selected, app.selected_node, app.active_diagram);
|
||||
let camera = (app.pan, app.zoom);
|
||||
let raw_epoch = app
|
||||
.source_symbols
|
||||
.raw_interaction
|
||||
.load(std::sync::atomic::Ordering::Acquire);
|
||||
let mut ui = UserInterface::build(
|
||||
draft_with_picker(&app),
|
||||
Size::new(500., 400.),
|
||||
Cache::new(),
|
||||
&mut (),
|
||||
);
|
||||
let event = key(Named::Escape);
|
||||
let (mut click, cursor) = press_at(iced::Point::new(50., 55.));
|
||||
let (mut messages, statuses) = if same_batch {
|
||||
ui.operate(&(), &mut FocusInputs);
|
||||
click.push(event.clone());
|
||||
update(&mut ui, &click, cursor)
|
||||
} else {
|
||||
assert!(update(&mut ui, &click, cursor).0.is_empty());
|
||||
let cache = ui.into_cache();
|
||||
ui = UserInterface::build(
|
||||
draft_with_picker(&app),
|
||||
Size::new(500., 400.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
ui.operate(&(), &mut FocusInputs);
|
||||
update(
|
||||
&mut ui,
|
||||
std::slice::from_ref(&event),
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
};
|
||||
assert_eq!(statuses.last(), Some(&iced::event::Status::Captured));
|
||||
subscriptions(&app, event, *statuses.last().unwrap(), &mut messages);
|
||||
assert!(
|
||||
messages.is_empty(),
|
||||
"picker must not forward global modal cancellation"
|
||||
);
|
||||
let cache = ui.into_cache();
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
|
||||
assert!(!crate::source_navigation::interaction::is_current(
|
||||
&app.source_symbols.raw_interaction,
|
||||
raw_epoch,
|
||||
));
|
||||
let mut ui = UserInterface::build(
|
||||
draft_with_picker(&app),
|
||||
Size::new(500., 400.),
|
||||
cache,
|
||||
&mut (),
|
||||
);
|
||||
let event = key(Named::Escape);
|
||||
let (mut messages, statuses) = update(
|
||||
&mut ui,
|
||||
std::slice::from_ref(&event),
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
subscriptions(&app, event, statuses[0], &mut messages);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
drop(ui);
|
||||
let _ = app.update(messages.remove(0));
|
||||
if source {
|
||||
assert!(app.behavior.creation.has_pending());
|
||||
assert!(!app.behavior.creation.source_open());
|
||||
assert_eq!(
|
||||
app.behavior.creation.recovery_snapshot().unwrap().unwrap()["name"],
|
||||
"Retained machine 雨"
|
||||
);
|
||||
} else {
|
||||
assert!(!app.behavior.creation.has_pending());
|
||||
}
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
(app.selected, app.selected_node, app.active_diagram),
|
||||
selection
|
||||
);
|
||||
assert_eq!((app.pan, app.zoom), camera);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_tooltip_escape_preserves_modal_priority_and_rejects_stale_focus() {
|
||||
use crate::source_navigation::interaction;
|
||||
use iced::widget::{text, tooltip};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
app.source_navigation.open = true;
|
||||
let project = app.project().clone();
|
||||
let source = app.document.bundle().clone();
|
||||
let context = (
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.active_diagram,
|
||||
app.pan,
|
||||
app.zoom,
|
||||
);
|
||||
let content = || -> Element<'_, Message, Theme, ()> {
|
||||
let field = text_input("Find", "Retained query 雨").on_input(|_| Message::Escape);
|
||||
let tip = tooltip(field, text("Help"), tooltip::Position::Bottom)
|
||||
.delay(std::time::Duration::ZERO);
|
||||
root(
|
||||
&app,
|
||||
interaction::observe(tip.into(), app.source_symbols.raw_interaction.clone()),
|
||||
)
|
||||
};
|
||||
let mut ui = UserInterface::build(content(), Size::new(400., 180.), Cache::new(), &mut ());
|
||||
let point = iced::Point::new(10., 10.);
|
||||
let cursor = mouse::Cursor::Available(point);
|
||||
assert!(
|
||||
update(
|
||||
&mut ui,
|
||||
&[Event::Mouse(mouse::Event::CursorMoved { position: point })],
|
||||
cursor
|
||||
)
|
||||
.0
|
||||
.is_empty()
|
||||
);
|
||||
let cache = ui.into_cache();
|
||||
let mut ui = UserInterface::build(content(), Size::new(400., 180.), cache, &mut ());
|
||||
ui.operate(&(), &mut FocusInputs);
|
||||
let epoch = app.source_symbols.raw_interaction.load(Ordering::Acquire);
|
||||
let event = key(Named::Escape);
|
||||
let (mut messages, statuses) = update(&mut ui, std::slice::from_ref(&event), cursor);
|
||||
assert_eq!(statuses, [iced::event::Status::Captured]);
|
||||
subscriptions(&app, event, statuses[0], &mut messages);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
assert!(!interaction::is_current(
|
||||
&app.source_symbols.raw_interaction,
|
||||
epoch
|
||||
));
|
||||
drop(ui);
|
||||
let _ = app.update(messages.remove(0));
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(
|
||||
(
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.active_diagram,
|
||||
app.pan,
|
||||
app.zoom
|
||||
),
|
||||
context
|
||||
);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggling_bridge_policy_retains_focused_child_state() {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
let content = || -> Element<'static, Message, Theme, ()> {
|
||||
text_input("Raw field", "Still focused")
|
||||
.on_input(|_| Message::Escape)
|
||||
.into()
|
||||
};
|
||||
let mut ui = UserInterface::build(
|
||||
root(&app, content()),
|
||||
Size::new(400., 100.),
|
||||
Cache::new(),
|
||||
&mut (),
|
||||
);
|
||||
ui.operate(&(), &mut FocusInputs);
|
||||
let cache = ui.into_cache();
|
||||
app.page = Page::Text;
|
||||
app.source_navigation.open = true;
|
||||
let mut ui = UserInterface::build(root(&app, content()), Size::new(400., 100.), cache, &mut ());
|
||||
let (messages, status) = update(&mut ui, &[key(Named::Escape)], mouse::Cursor::Unavailable);
|
||||
assert_eq!(status, [iced::event::Status::Captured]);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_creation_name_escape_keeps_pending_modal_priority() {
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use crate::behavior::{Event as BehaviorEvent, creation};
|
||||
for pending in [false, true] {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
for event in [
|
||||
creation::Event::Open(Layer::Oa),
|
||||
creation::Event::Choose(creation::Family::Sequence),
|
||||
creation::Event::Name("Exact unfinished process 雨".into()),
|
||||
] {
|
||||
let _ = app.update(Message::Behavior(BehaviorEvent::Creation(event)));
|
||||
}
|
||||
app.page = Page::Behavior;
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
mounted
|
||||
.perform(
|
||||
&mut app,
|
||||
iced::widget::operation::focus(creation::NAME_INPUT),
|
||||
)
|
||||
.await;
|
||||
if pending {
|
||||
app.pending = Some(super::super::Pending::Walkthrough);
|
||||
}
|
||||
let before = app.document.snapshot();
|
||||
let draft = app.behavior.recovery_snapshot().unwrap();
|
||||
let event = key(Named::Escape);
|
||||
let (mut messages, statuses) = mounted.input_with_status(
|
||||
&app,
|
||||
std::slice::from_ref(&event),
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
subscriptions(&app, event, statuses[0], &mut messages);
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| matches!(message, Message::Escape))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
if pending {
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), draft);
|
||||
} else {
|
||||
assert!(!app.behavior.creation.has_pending());
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,202 @@ fn assert_source_matches_model(app: &App) {
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
fn text_apply_group_fixture(state: bool) -> (App, Id, Id, Id) {
|
||||
let ordinary = fixture();
|
||||
if !state {
|
||||
return (ordinary.app, ordinary.diagram, ordinary.a, ordinary.b);
|
||||
}
|
||||
// Keep the ordinary diagram, routes and opposite-layer view in the same
|
||||
// Project so the complete-model oracle also protects non-active surfaces.
|
||||
let mut project = ordinary.app.project().clone();
|
||||
let ordinary_view = project.diagrams[&ordinary.diagram].clone();
|
||||
let system = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let first = project
|
||||
.add_element(Kind::State, Layer::Sa, "Before text", Some(region))
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::State, Layer::Sa, "Other state", Some(region))
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
assert_ne!(diagram, ordinary.diagram);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::StateMachine { machine };
|
||||
let a = project.place(diagram, first, point(20., 30.));
|
||||
let b = project.place(diagram, second, point(420., 30.));
|
||||
assert_eq!(project.diagrams[&ordinary.diagram], ordinary_view);
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(ordinary.diagram);
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram = crate::behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
..Default::default()
|
||||
};
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.state_context().unwrap().diagram, diagram);
|
||||
(app, diagram, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_text_apply_rejects_captured_ordinary_and_state_group_actions() {
|
||||
for state in [false, true] {
|
||||
let (mut app, diagram, a, b) = text_apply_group_fixture(state);
|
||||
choose(&mut app, Target::Placement(a), GroupChoice::Replace);
|
||||
choose(&mut app, Target::Placement(b), GroupChoice::Add);
|
||||
let stale = app.multiselect_snapshot().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
assert!(!app.document.can_undo());
|
||||
let element = app.project().diagrams[&diagram].nodes[&a].element;
|
||||
let location = app.document.compiled().index.declarations[&element].clone();
|
||||
let old_name = if state { "Before text" } else { "Send" };
|
||||
assert_eq!(app.project().elements[&element].name, old_name);
|
||||
let quoted_name = format!("\"{old_name}\"");
|
||||
let original = &before.bundle.files[&location.path];
|
||||
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
|
||||
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
|
||||
let mut expected_model = before.decoded.project.clone();
|
||||
expected_model.elements.get_mut(&element).unwrap().name = "Changed through Text".into();
|
||||
let mut expected_bundle = before.bundle.clone();
|
||||
expected_bundle
|
||||
.files
|
||||
.insert(location.path.clone(), edited.clone());
|
||||
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let _ = app.update(Message::SourceFile(location.path));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(edited.clone())),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
assert_eq!(app.source_editor.text(), edited);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::ApplySource);
|
||||
assert!(!app.source_dirty(), "{}", app.status);
|
||||
assert_eq!(app.project(), &expected_model);
|
||||
assert_eq!(app.document.bundle(), &expected_bundle);
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
assert_source_matches_model(&app);
|
||||
|
||||
let _ = app.update(Message::Page(if state {
|
||||
Page::Behavior
|
||||
} else {
|
||||
Page::Diagram
|
||||
}));
|
||||
choose(&mut app, Target::Placement(a), GroupChoice::Replace);
|
||||
choose(&mut app, Target::Placement(b), GroupChoice::Add);
|
||||
let current = app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
current.targets(),
|
||||
&[Target::Placement(a), Target::Placement(b)]
|
||||
);
|
||||
assert_eq!(current.primary(), Some(&Target::Placement(b)));
|
||||
assert_ne!(current.context(), stale.context());
|
||||
assert!(app.multiselect_envelope(&GroupEvent::Hide(current.clone())));
|
||||
app.status = "Feedback after completed Text Apply".into();
|
||||
app.allocation_drag.arrange_next = true;
|
||||
let revision = app.document.revision();
|
||||
let epochs = (
|
||||
app.canvas_epoch,
|
||||
app.camera_epoch,
|
||||
app.allocation_drag.epoch,
|
||||
);
|
||||
let legacy = (
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.diagram.placement,
|
||||
);
|
||||
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
|
||||
let state_camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
app.behavior.diagram.viewport,
|
||||
);
|
||||
let draft = app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
for event in [
|
||||
GroupEvent::Move(stale.clone(), point(900., 800.)),
|
||||
GroupEvent::Hide(stale.clone()),
|
||||
GroupEvent::Collapse(stale.clone(), Target::Placement(a)),
|
||||
GroupEvent::Refuse(stale.clone(), "Obsolete refusal".into()),
|
||||
] {
|
||||
dispatch(&mut app, event);
|
||||
assert_eq!(app.project(), &expected_model);
|
||||
assert_eq!(app.document.bundle(), &expected_bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.multiselect_snapshot(), Some(current.clone()));
|
||||
assert_eq!(
|
||||
(
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.diagram.placement
|
||||
),
|
||||
legacy
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.canvas_epoch,
|
||||
app.camera_epoch,
|
||||
app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
app.behavior.diagram.viewport
|
||||
),
|
||||
state_camera
|
||||
);
|
||||
assert_eq!(app.status, "Feedback after completed Text Apply");
|
||||
assert!(app.allocation_drag.arrange_next);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
// Actual Text Apply is the sole history entry; no stale group action
|
||||
// can create a hidden second edit or damage either round-trip snapshot.
|
||||
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_model);
|
||||
assert_eq!(app.document.bundle(), &expected_bundle);
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
|
||||
let mut f = fixture();
|
||||
|
||||
@@ -65,6 +65,7 @@ impl App {
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
scenario_view: None,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|
||||
@@ -144,6 +144,151 @@ fn marker(app: &mut App, owner: Id, occurrence: Id) {
|
||||
assert!(app.diagram_focus(Surface::Process(owner)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_text_apply_rejects_captured_process_reorder_without_retiring_current_state() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
assert!(!f.app.document.can_undo());
|
||||
let element = f.nodes[0];
|
||||
let location = f.app.document.compiled().index.declarations[&element].clone();
|
||||
let original = &before.bundle.files[&location.path];
|
||||
let quoted_name = format!("\"{}\"", f.app.project().elements[&element].name);
|
||||
assert_eq!(original.matches(quoted_name.as_str()).count(), 1);
|
||||
let edited = original.replace(quoted_name.as_str(), "\"Changed through Text\"");
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.elements.get_mut(&element).unwrap().name = "Changed through Text".into();
|
||||
let mut expected_bundle = before.bundle.clone();
|
||||
expected_bundle
|
||||
.files
|
||||
.insert(location.path.clone(), edited.clone());
|
||||
|
||||
let _ = f.app.update(Message::Page(Page::Text));
|
||||
let _ = f.app.update(Message::SourceFile(location.path));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = f.app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(edited.clone())),
|
||||
)));
|
||||
assert!(f.app.source_dirty());
|
||||
assert_eq!(f.app.source_editor.text(), edited);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
let _ = f.app.update(Message::ApplySource);
|
||||
assert!(!f.app.source_dirty(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &expected_bundle);
|
||||
source_matches(&f.app);
|
||||
|
||||
let _ = f.app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(f.app.topology_context().unwrap().owner, f.owner);
|
||||
pair(&mut f);
|
||||
let current = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(current.targets(), stale.targets());
|
||||
assert_eq!(current.primary(), stale.primary());
|
||||
assert_ne!(current.context(), stale.context());
|
||||
let destination = Destination::After(f.nodes[2]);
|
||||
assert!(
|
||||
matches!(
|
||||
Prepared::new(f.app.project(), f.owner, current.targets())
|
||||
.unwrap()
|
||||
.plan(destination)
|
||||
.unwrap(),
|
||||
Plan::Change(_)
|
||||
),
|
||||
"the replay must be a real reorder, not a no-op"
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_envelope(&Event::ProcessReorder(current.clone(), destination))
|
||||
);
|
||||
let accepted = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let workspace = f.app.source_workspace.bundle();
|
||||
let editor = f.app.source_editor.text();
|
||||
let selected = (
|
||||
f.app.selected,
|
||||
f.app.selected_node,
|
||||
f.app.selected_bend,
|
||||
f.app.selected_attachment,
|
||||
);
|
||||
let camera = (f.app.zoom, f.app.pan, f.app.fit_to_view, f.app.canvas_size);
|
||||
let process_camera = f.app.behavior.topology.camera_boundary();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let form = f.app.behavior.recovery_snapshot();
|
||||
let status = f.app.status.clone();
|
||||
let pending_reveal = f.app.pending_diagram_reveal.clone();
|
||||
|
||||
send(&mut f.app, Event::ProcessReorder(stale, destination));
|
||||
assert!(Arc::ptr_eq(&accepted, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &expected_bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(f.app.source_workspace.bundle(), workspace);
|
||||
assert_eq!(f.app.source_editor.text(), editor);
|
||||
assert_eq!(f.app.multiselect_snapshot(), Some(current));
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.selected,
|
||||
f.app.selected_node,
|
||||
f.app.selected_bend,
|
||||
f.app.selected_attachment
|
||||
),
|
||||
selected
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.zoom, f.app.pan, f.app.fit_to_view, f.app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert!(f.app.behavior.topology.camera_boundary() == process_camera);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot(), form);
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending_reveal);
|
||||
assert_eq!(f.app.status, status);
|
||||
assert!(!f.app.source_dirty());
|
||||
assert!(f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
|
||||
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());
|
||||
assert!(f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &expected_bundle);
|
||||
assert!(f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
source_matches(&f.app);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_group_reverse_click_order_changes_only_owner_steps_and_undoes_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Actual arrangement selection and current-record Details admission.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use iced::{Point, Size, advanced::widget};
|
||||
|
||||
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
|
||||
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn setup() -> (App, Id, Id, Id) {
|
||||
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
|
||||
let a = id(&app, "incident_readable");
|
||||
let b = id(&app, "incident_wide");
|
||||
let owner = id(&app, "incident_response");
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
choose(&mut app, Representation::Saved { diagram: a });
|
||||
let _ = app.update(Message::Select(a));
|
||||
app.window_size = Size::new(1440., 1000.);
|
||||
(app, a, b, owner)
|
||||
}
|
||||
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(event(context)));
|
||||
}
|
||||
fn choose(app: &mut App, representation: Representation) {
|
||||
send(app, |context| {
|
||||
CanvasEvent::Representation(context, representation)
|
||||
});
|
||||
}
|
||||
fn model_state(app: &App) -> (u64, bool, bool, bool) {
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.dirty(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
|
||||
let (mut app, a, b, _) = setup();
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let snapshot = app.document.snapshot();
|
||||
let before = model_state(&app);
|
||||
let captured = app.scenario_context().unwrap();
|
||||
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
|
||||
assert!(
|
||||
app.window_size.height - (picker.y + picker.height) > picker.y,
|
||||
"fixture has room for Iced's menu below the actual picker"
|
||||
);
|
||||
assert_eq!(app.selected, a);
|
||||
mounted.click(&mut app, picker.center()).await;
|
||||
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
|
||||
// vertical padding for each row. The real workspace orders Automatic,
|
||||
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
|
||||
let row_height =
|
||||
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
|
||||
let b_row = Point::new(
|
||||
picker.center_x(),
|
||||
picker.y + picker.height + row_height * 2.5,
|
||||
);
|
||||
assert!(b_row.y < app.window_size.height);
|
||||
let events = mounted.click(&mut app, b_row).await;
|
||||
assert!(events.iter().any(|event| matches!(event,
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
|
||||
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, b);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().value["name"],
|
||||
"B - Incident review negative and far"
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(b));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: b }
|
||||
);
|
||||
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
|
||||
|
||||
send(&mut app, |context| {
|
||||
CanvasEvent::Shelf(context, Shelf::Selection)
|
||||
});
|
||||
mounted.settle(&mut app).await;
|
||||
let geometry = mounted.geometry(&app);
|
||||
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
|
||||
let point = geometry.bounds("scenario-properties").center() - offset;
|
||||
assert!(details.contains(point));
|
||||
let events = mounted.click(&mut app, point).await;
|
||||
assert!(events.iter().any(|event| matches!(event,
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
|
||||
mounted.settle(&mut app).await;
|
||||
let geometry = mounted.geometry(&app);
|
||||
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
|
||||
assert!(
|
||||
geometry
|
||||
.text_inputs
|
||||
.iter()
|
||||
.any(|bounds| inspector.contains(bounds.center() - offset)),
|
||||
"current B Properties must expose an actual editable field"
|
||||
);
|
||||
assert!(
|
||||
!geometry
|
||||
.scrolls
|
||||
.contains_key(&widget::Id::new("scenario-details"))
|
||||
);
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Properties
|
||||
);
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, b);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &snapshot.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &snapshot.bundle);
|
||||
assert_eq!(model_state(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
|
||||
for alias in [
|
||||
"initial_report",
|
||||
"reporter",
|
||||
"incident_response",
|
||||
"incident_repair",
|
||||
] {
|
||||
let (mut app, _, b, _) = setup();
|
||||
let selected = id(&app, alias);
|
||||
let _ = app.update(Message::Select(selected));
|
||||
let snapshot = app.document.snapshot();
|
||||
let before = model_state(&app);
|
||||
choose(&mut app, Representation::Saved { diagram: b });
|
||||
assert_eq!(app.selected, selected, "independent selection {alias}");
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, selected);
|
||||
assert_eq!(app.active_diagram, Some(b));
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &snapshot.bundle);
|
||||
assert_eq!(model_state(&app), before);
|
||||
if alias == "incident_repair" {
|
||||
assert!(!behavior::scenario::properties_target(
|
||||
app.project(),
|
||||
app.scenario_context().unwrap(),
|
||||
selected
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
|
||||
let (mut app, _, b, owner) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
let before = model_state(&app);
|
||||
choose(&mut app, Representation::Derived { owner });
|
||||
assert_eq!(app.selected, owner);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, owner);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
assert!(behavior::scenario::properties_target(
|
||||
app.project(),
|
||||
app.scenario_context().unwrap(),
|
||||
owner
|
||||
));
|
||||
choose(&mut app, Representation::Saved { diagram: b });
|
||||
assert_eq!(
|
||||
app.selected, owner,
|
||||
"Scenario owner is now independent semantic selection"
|
||||
);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, owner);
|
||||
assert_eq!(app.active_diagram, Some(b));
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &snapshot.bundle);
|
||||
assert_eq!(model_state(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
|
||||
let (mut app, a, b, _) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
let mut expected = app.project().clone();
|
||||
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
|
||||
choose(&mut app, Representation::Saved { diagram: b });
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, b);
|
||||
assert!(!app.draft_dirty());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &snapshot.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &snapshot.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert!(app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
|
||||
let (mut app, a, _, _) = setup();
|
||||
let snapshot = app.document.snapshot();
|
||||
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
|
||||
let mut expected = app.project().clone();
|
||||
let mut diagram = expected.diagrams.remove(&a).unwrap();
|
||||
diagram.id = replacement;
|
||||
expected.diagrams.insert(replacement, diagram);
|
||||
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
choose(&mut app, Representation::Saved { diagram: a });
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.selected, replacement);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
|
||||
assert_eq!(app.active_diagram, Some(replacement));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved {
|
||||
diagram: replacement
|
||||
}
|
||||
);
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &snapshot.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &snapshot.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
|
||||
let (mut app, a, b, _) = setup();
|
||||
let old = app.scenario_context().unwrap();
|
||||
let snapshot = app.document.snapshot();
|
||||
let before = model_state(&app);
|
||||
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
|
||||
let draft_value = app.draft.as_ref().unwrap().value.clone();
|
||||
choose(&mut app, Representation::Saved { diagram: b });
|
||||
assert_eq!(app.selected, a);
|
||||
assert_eq!(app.active_diagram, Some(a));
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
old.representation
|
||||
);
|
||||
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
|
||||
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
|
||||
assert!(app.draft_dirty());
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(model_state(&app), before);
|
||||
let _ = app.update(Message::RevertProperties);
|
||||
choose(&mut app, Representation::Saved { diagram: b });
|
||||
let current = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
|
||||
let selected = app.selected;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
old,
|
||||
Representation::Saved { diagram: a },
|
||||
)));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.active_diagram, Some(b));
|
||||
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Retained B input"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &snapshot.bundle);
|
||||
assert_eq!(model_state(&app), before);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Saved Scenario navigation is presentation state, never a document edit.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
|
||||
|
||||
impl App {
|
||||
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
|
||||
let Some(owner) = checked_owner(self.project(), representation) else {
|
||||
self.status =
|
||||
"This Scenario view is no longer available or has an incompatible owner.".into();
|
||||
return false;
|
||||
};
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(owner);
|
||||
match representation {
|
||||
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
|
||||
Representation::Derived { .. } => {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
self.active_diagram = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selected_node = None;
|
||||
if self
|
||||
.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, representation)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile after navigation/adoption without selecting or applying a draft.
|
||||
/// A retained invalid Saved identity remains unavailable; it must not silently
|
||||
/// turn into a different automatic Scenario or another saved arrangement.
|
||||
pub(super) fn reconcile_scenario_workspace(&mut self) {
|
||||
if self.page == Page::Diagram
|
||||
&& !self.behavior.has_pending()
|
||||
&& let Some(diagram) = self
|
||||
.active_diagram
|
||||
.filter(|id| self.saved_scenario_diagram(*id))
|
||||
{
|
||||
self.enter_scenario_representation(Representation::Saved { diagram });
|
||||
}
|
||||
if self.page != Page::Behavior {
|
||||
self.behavior.scenario.leave();
|
||||
return;
|
||||
}
|
||||
if let Some(rep @ Representation::Saved { diagram }) =
|
||||
self.behavior.scenario.representation()
|
||||
{
|
||||
// The requested view is authoritative until explicit navigation
|
||||
// retires it. This also recovers the same view when Undo restores it.
|
||||
if let Some(owner) = checked_owner(self.project(), rep) {
|
||||
self.active_diagram = Some(diagram);
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, rep);
|
||||
} else if let Some(owner) = self.behavior.scenario.owner() {
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, rep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(owner) = behavior::selected_owner(self) else {
|
||||
self.behavior.scenario.leave();
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
self.behavior.scenario.enter(self.canvas_document, owner);
|
||||
} else {
|
||||
self.behavior.scenario.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod adoption_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod owner_navigation_tests;
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Independent adoption oracles: expected Projects are edited directly in the
|
||||
//! fixture, then admitted through the actual source editor/Document transaction.
|
||||
//! Navigation is checked separately from the expected authored mutation.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use iced::advanced::widget;
|
||||
use iced::widget::text_editor;
|
||||
use oa_core::language;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
same_layer: Id,
|
||||
other_layer: Id,
|
||||
view: Id,
|
||||
spare_view: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
|
||||
.unwrap();
|
||||
let same_layer = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
|
||||
.unwrap();
|
||||
let other_layer = project
|
||||
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
|
||||
.unwrap();
|
||||
// A remaining topology makes silent fallback observably different from the
|
||||
// required unavailable Saved view after its owner is deleted.
|
||||
project
|
||||
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
|
||||
.unwrap();
|
||||
let mut make_view = |alias: &str, name: &str, owner| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
};
|
||||
let view = make_view("retained_view", "Retained arrangement", owner);
|
||||
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
choose(&mut app, view);
|
||||
assert_saved(&app, view, owner, Layer::Oa);
|
||||
assert!(!app.document.can_undo());
|
||||
Fixture {
|
||||
app,
|
||||
owner,
|
||||
same_layer,
|
||||
other_layer,
|
||||
view,
|
||||
spare_view,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id,
|
||||
label: "Previously captured picker item".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
let context = app
|
||||
.scenario_context()
|
||||
.expect("saved owner remains the visible context");
|
||||
assert_eq!(context.scenario, owner);
|
||||
assert_eq!(
|
||||
context.representation,
|
||||
Representation::Saved { diagram: view }
|
||||
);
|
||||
assert!(!context.visit.is_nil());
|
||||
}
|
||||
|
||||
fn adopt_from_source(app: &mut App, expected: &Project) {
|
||||
let text = language::format(expected);
|
||||
// Parser equality is a fixture precondition, not the navigation oracle.
|
||||
assert_eq!(language::parse(&text).unwrap(), *expected);
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let _ = app.update(Message::ApplySource);
|
||||
assert!(!app.source_dirty(), "{}", app.status);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
expected,
|
||||
"actual source transaction must adopt exactly the fixture change"
|
||||
);
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
|
||||
fn retarget(cross_layer: bool) {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let old_context = f.app.scenario_context().unwrap();
|
||||
let revision = f.app.document.revision();
|
||||
let target = if cross_layer {
|
||||
f.other_layer
|
||||
} else {
|
||||
f.same_layer
|
||||
};
|
||||
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.view).unwrap();
|
||||
view.projection = ProjectionKind::Scenario { scenario: target };
|
||||
view.layer = layer;
|
||||
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
assert_saved(&f.app, f.view, target, layer);
|
||||
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
|
||||
assert_eq!(
|
||||
f.app.document.revision(),
|
||||
revision + 1,
|
||||
"routing must not create a second transaction"
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&f.spare_view],
|
||||
before.decoded.project.diagrams[&f.spare_view]
|
||||
);
|
||||
let adopted_bundle = f.app.document.bundle().clone();
|
||||
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!f.app.document.can_undo(),
|
||||
"exactly one authored adoption must be undone"
|
||||
);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_saved(&f.app, f.view, target, layer);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted_bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
|
||||
retarget(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
|
||||
retarget(true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
assert!(expected.elements.remove(&f.owner).is_some());
|
||||
assert!(expected.diagrams.remove(&f.view).is_some());
|
||||
assert!(expected.elements.contains_key(&f.same_layer));
|
||||
assert!(
|
||||
expected
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| e.kind() == Kind::Process)
|
||||
);
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
assert_eq!(
|
||||
f.app.behavior.scenario.representation(),
|
||||
Some(Representation::Saved { diagram: f.view })
|
||||
);
|
||||
let adopted = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let geometry = mounted.geometry(&f.app);
|
||||
assert!(
|
||||
geometry
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
|
||||
);
|
||||
assert!(
|
||||
!geometry
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport"))
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted.bundle);
|
||||
assert_eq!(
|
||||
f.app.document.revision(),
|
||||
revision,
|
||||
"mounting unavailable must remain passive"
|
||||
);
|
||||
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(
|
||||
f.app.behavior.scenario.representation(),
|
||||
Some(Representation::Saved { diagram: f.view })
|
||||
);
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(&f.app)
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
|
||||
let mut f = fixture();
|
||||
let mut expected = f.app.project().clone();
|
||||
expected.diagrams.remove(&f.spare_view).unwrap();
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
let _ = f.app.update(Message::Select(f.owner));
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending valid owner rename".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let selected = f.app.selected;
|
||||
for invalid in [Id::nil(), f.spare_view] {
|
||||
choose(&mut f.app, invalid);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.selected, selected);
|
||||
assert!(f.app.draft_dirty());
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
}
|
||||
// Establish that the retained draft really was valid, not merely refused by
|
||||
// the property validator: explicit Apply commits it in exactly one step.
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(
|
||||
f.app.project().elements[&f.owner].name,
|
||||
"Pending valid owner rename"
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Explicit owner navigation must retire a retained Saved Scenario, including
|
||||
//! when selection was made on a different page. These are real App events.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::CanvasEvent;
|
||||
|
||||
fn setup(kind: Kind) -> (App, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(kind, Layer::Oa, "Explicit destination", None)
|
||||
.unwrap();
|
||||
let view = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
view,
|
||||
Diagram {
|
||||
id: view,
|
||||
alias: "incident_saved".into(),
|
||||
name: "Incident saved arrangement".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario },
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
let errors: Vec<_> = oa_core::validation::validate(&project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(scenario));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: view,
|
||||
label: "Incident saved arrangement".into(),
|
||||
}));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: view }
|
||||
);
|
||||
assert_eq!(app.selected, scenario);
|
||||
(app, scenario, target)
|
||||
}
|
||||
|
||||
fn assert_destination(app: &App, target: Id) {
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target));
|
||||
assert_eq!(behavior::selected_owner(app), Some(target));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.representation(),
|
||||
None,
|
||||
"explicit non-Scenario destination must not retain an overriding Saved request"
|
||||
);
|
||||
assert!(app.scenario_context().is_none());
|
||||
}
|
||||
|
||||
fn exercise(via_guide: bool) {
|
||||
for kind in [Kind::Process, Kind::StateMachine] {
|
||||
let (mut app, scenario, target) = setup(kind);
|
||||
assert_ne!(scenario, target);
|
||||
let held = app.scenario_context().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let drafts = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
if via_guide {
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
assert_eq!(app.page, Page::Guide);
|
||||
assert!(app.scenario_context().is_none());
|
||||
}
|
||||
let _ = app.update(Message::Select(target));
|
||||
if via_guide {
|
||||
assert_eq!(
|
||||
app.page,
|
||||
Page::Guide,
|
||||
"explorer selection must not itself switch the page"
|
||||
);
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
assert_destination(&app, target);
|
||||
let camera = app.camera_boundary();
|
||||
// The prior saved canvas cannot open its outline after either owner route.
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf(
|
||||
held,
|
||||
crate::behavior::scenario::Shelf::Selection,
|
||||
)));
|
||||
assert_destination(&app, target);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), drafts);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
(app.document.dirty(), app.dirty(), app.source_dirty()),
|
||||
dirty
|
||||
);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() {
|
||||
exercise(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() {
|
||||
exercise(false);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
|
||||
|
||||
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{}", p.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 setup() -> (App, Id, Id, Id, Id) {
|
||||
let (mut p, [owner, callee, _], [reference, _, _]) =
|
||||
behavior::scenario::direct_tests::navigation_fixture();
|
||||
let a = saved(&mut p, owner, "Review A");
|
||||
let b = saved(&mut p, owner, "Review B");
|
||||
let mut app = App::with_project(p);
|
||||
app.select(owner);
|
||||
(app, a, b, reference, callee)
|
||||
}
|
||||
fn choose(app: &mut App, diagram: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Captured view".into(),
|
||||
}));
|
||||
}
|
||||
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(f(context)));
|
||||
}
|
||||
#[test]
|
||||
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let owner = app.selected;
|
||||
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
|
||||
let edited = app.document.snapshot();
|
||||
let edited_revision = app.document.revision();
|
||||
choose(&mut app, a);
|
||||
let first = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
let second = app.scenario_context().unwrap();
|
||||
choose(&mut app, a);
|
||||
let third = app.scenario_context().unwrap();
|
||||
assert_eq!(first.scenario, owner);
|
||||
assert_eq!(first.representation, Representation::Saved { diagram: a });
|
||||
assert_eq!(second.representation, Representation::Saved { diagram: b });
|
||||
assert_ne!(first.visit, second.visit);
|
||||
assert_ne!(first.visit, third.visit);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, owner);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
assert_eq!(app.document.revision(), edited_revision);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
}
|
||||
#[test]
|
||||
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let old = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
choose(&mut app, a);
|
||||
let current = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let before = app.document.snapshot();
|
||||
for stale in [
|
||||
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
|
||||
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
|
||||
CanvasEvent::FormShown(old, Id::new_v4()),
|
||||
CanvasEvent::Restored(
|
||||
old,
|
||||
Id::new_v4(),
|
||||
ScrollArea::Page,
|
||||
iced::Vector::new(0., 800.),
|
||||
),
|
||||
CanvasEvent::Camera(
|
||||
old,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(stale));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
|
||||
let (mut app, a, _, reference, callee) = setup();
|
||||
choose(&mut app, a);
|
||||
let caller = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
let target = app.scenario_context().unwrap();
|
||||
assert_eq!(target.scenario, callee);
|
||||
assert_eq!(
|
||||
target.representation,
|
||||
Representation::Derived { owner: callee }
|
||||
);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
event(&mut app, CanvasEvent::Back);
|
||||
let returned = app.scenario_context().unwrap();
|
||||
assert_eq!(returned.representation, caller.representation);
|
||||
assert_eq!(app.active_diagram, Some(a));
|
||||
assert_eq!(app.selected, reference);
|
||||
assert_ne!(returned.visit, caller.visit);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
|
||||
choose(&mut app, b);
|
||||
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: b }
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
choose(&mut app, a);
|
||||
let replacement = Id::new_v4();
|
||||
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
choose(&mut app, a);
|
||||
assert!(!app.project().diagrams.contains_key(&a));
|
||||
assert_eq!(app.active_diagram, Some(replacement));
|
||||
assert_eq!(app.selected, replacement);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved {
|
||||
diagram: replacement
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
|
||||
let (mut app, a, _, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let old = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
assert!(app.scenario_context().is_none());
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let fresh = app.scenario_context().unwrap();
|
||||
assert_eq!(fresh.representation, old.representation);
|
||||
assert_ne!(fresh.visit, old.visit);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let stale = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
let current = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
stale,
|
||||
Representation::Saved { diagram: a },
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
event(&mut app, |context| {
|
||||
CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Derived {
|
||||
owner: context.scenario,
|
||||
},
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Derived {
|
||||
owner: current.scenario
|
||||
}
|
||||
);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
|
||||
use super::*;
|
||||
use behavior::scenario::spacing::{Event, controller::Finish};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn spacing_enabled(&self) -> bool {
|
||||
self.pending_diagram_reveal.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.source_rename.open()
|
||||
&& behavior::canvas_workspace::keyboard_enabled(self)
|
||||
}
|
||||
|
||||
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
||||
let stamp = event.stamp();
|
||||
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
||||
{
|
||||
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,
|
||||
} {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Begin { .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_spacing(self.project(), event);
|
||||
self.behavior.scenario = state;
|
||||
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, .. } => {
|
||||
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)) => self.select(target),
|
||||
Ok(Finish::Noop) => {}
|
||||
Ok(Finish::Commit {
|
||||
command,
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
}) => {
|
||||
// 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();
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.status =
|
||||
"That position cannot be applied. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
use super::*;
|
||||
use behavior::scenario::{
|
||||
CanvasEvent,
|
||||
spacing::{
|
||||
Target,
|
||||
controller::tests::{fixture, packet},
|
||||
},
|
||||
};
|
||||
use iced::{Point, Size, Vector};
|
||||
|
||||
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));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
let c = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
c,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.spacing_enabled());
|
||||
(app, lane, message)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let previous = app.scenario_context().unwrap();
|
||||
let diagram = match previous.representation {
|
||||
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let old_indent = Message::SourceIndent(app.source_symbol_context(), false);
|
||||
let old_drawer = Message::TextWorkspace(text_workspace::Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
text_workspace::DrawerTab::Problems,
|
||||
));
|
||||
let old_command = app.text_command_message(text_workspace::Command::ShowStatus);
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_ne!(context.visit, previous.visit);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
context,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
let context = app.scenario_context().unwrap();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(24., 0.));
|
||||
let accepted = app.document.snapshot();
|
||||
let source = app.source_workspace.bundle();
|
||||
let source_version = app.source_version;
|
||||
let camera = app.camera_boundary();
|
||||
let selected = app.selected;
|
||||
let events = [
|
||||
old_indent,
|
||||
old_drawer,
|
||||
old_command,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(
|
||||
previous,
|
||||
behavior::scenario::Shelf::Selection,
|
||||
)),
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(previous, lane)),
|
||||
];
|
||||
for rejected in events {
|
||||
let _ = app.update(rejected);
|
||||
assert_eq!(app.scenario_context(), Some(context));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
assert_eq!(app.behavior.scenario.spacing_stamp(), Some(event.stamp()));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.source_version, source_version);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
finish(&mut app, event, start + Vector::new(24., 0.));
|
||||
assert!(
|
||||
app.document.can_undo(),
|
||||
"the current gesture remains usable"
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
let Event::Begin {
|
||||
press_screen,
|
||||
scale,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
(event, press_screen, scale)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
}
|
||||
fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Move {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
},
|
||||
);
|
||||
}
|
||||
fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Finish {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let source = app.source_workspace.bundle();
|
||||
let selected = app.selected;
|
||||
let epoch = app.canvas_epoch;
|
||||
let (event, start, scale) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(40., 300.));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
finish(&mut app, event, start + Vector::new(40., 300.));
|
||||
assert_eq!(app.selected, lane);
|
||||
assert!(app.document.can_undo() && !app.document.can_redo());
|
||||
let accepted = app.document.snapshot();
|
||||
assert_ne!(accepted.bundle, original.bundle);
|
||||
let expected = behavior::scenario::spacing::controller::tests::expected(
|
||||
&original.decoded.project,
|
||||
match event.stamp().context.representation {
|
||||
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
|
||||
_ => unreachable!(),
|
||||
},
|
||||
Target::Lane(lane),
|
||||
40. / scale,
|
||||
);
|
||||
assert_eq!(app.project(), &expected);
|
||||
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(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() {
|
||||
let (mut app, lane, message) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Message(message));
|
||||
finish(&mut app, event, start + Vector::new(30., 2.));
|
||||
assert_eq!(app.selected, message);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
for cancel in [false, true] {
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(30., 0.));
|
||||
if cancel {
|
||||
send(
|
||||
&mut app,
|
||||
Event::Cancel {
|
||||
stamp: event.stamp(),
|
||||
token: event.token(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
finish(&mut app, event, start);
|
||||
}
|
||||
assert_eq!(app.selected, message);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
let held_epoch = app.canvas_epoch;
|
||||
send(
|
||||
&mut app,
|
||||
Event::Finish {
|
||||
stamp: event.stamp(),
|
||||
token: Id::new_v4(),
|
||||
screen: start + Vector::new(30., 0.),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.canvas_epoch, held_epoch);
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
let owner = event.stamp().context.scenario;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
event.stamp().context,
|
||||
behavior::scenario_layout::Representation::Derived { owner },
|
||||
)));
|
||||
let current = app.scenario_context();
|
||||
let epoch = app.canvas_epoch;
|
||||
let original = app.document.snapshot();
|
||||
finish(&mut app, event, start + Vector::new(50., 0.));
|
||||
assert_eq!(app.scenario_context(), current);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_write_failure_cannot_commit_candidate_or_materialize_camera() {
|
||||
let (mut app, lane, _) = setup();
|
||||
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 selected = app.selected;
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(40., 0.));
|
||||
finish(&mut app, event, start + Vector::new(40., 0.));
|
||||
assert!(
|
||||
app.status.starts_with("Spacing was not applied:"),
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() {
|
||||
for source in [false, true] {
|
||||
let (mut app, lane, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(30., 0.));
|
||||
if source {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
} else {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Scenario(
|
||||
behavior::scenario::Event::Start(
|
||||
event.stamp().context.scenario,
|
||||
behavior::scenario::Tool::Lifeline,
|
||||
),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
let draft = app.source_workspace.bundle();
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
finish(&mut app, event, start + Vector::new(30., 0.));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), draft);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
|
||||
let (mut app, lane, message) = setup();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
let behavior::scenario_layout::Representation::Saved { diagram } =
|
||||
event.stamp().context.representation
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::RevealInDiagram(diagram, message));
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
let epoch = app.canvas_epoch;
|
||||
let selected = app.selected;
|
||||
let visit = app.pending_scenario_reveal_visit;
|
||||
let original = app.document.snapshot();
|
||||
finish(&mut app, event, start + Vector::new(50., 0.));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.pending_scenario_reveal_visit, visit);
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_active_actions_cannot_remove_new_same_visit_deferred_reveal() {
|
||||
let (mut app, lane, message) = setup();
|
||||
let old = app.scenario_context().unwrap();
|
||||
let behavior::scenario_layout::Representation::Saved { diagram } = old.representation else {
|
||||
unreachable!()
|
||||
};
|
||||
let accepted = app.document.snapshot();
|
||||
let source = app.source_workspace.bundle();
|
||||
let _ = app.update(Message::RevealInDiagram(diagram, message));
|
||||
let current = app.scenario_context().unwrap();
|
||||
assert_eq!(current.visit, old.visit);
|
||||
assert_eq!(current.representation, old.representation);
|
||||
assert_eq!(current.revision, old.revision);
|
||||
assert_ne!(current.epoch, old.epoch);
|
||||
let pending = app.pending_diagram_reveal.clone();
|
||||
assert!(pending.is_some());
|
||||
let focus = app.diagram_navigation_focus.as_ref().unwrap();
|
||||
let focus = (focus.context, focus.usage.clone(), focus.selected);
|
||||
let camera = app.camera_boundary();
|
||||
for event in [
|
||||
CanvasEvent::Representation(old, old.representation),
|
||||
CanvasEvent::Shelf(old, behavior::scenario::Shelf::Selection),
|
||||
CanvasEvent::Select(old, lane),
|
||||
CanvasEvent::Zoom(old, Some(1.)),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(event));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
assert_eq!(app.pending_diagram_reveal, pending);
|
||||
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
|
||||
let actual = app.diagram_navigation_focus.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(actual.context, actual.usage.clone(), actual.selected),
|
||||
focus
|
||||
);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.selected, message);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
current,
|
||||
Size::new(800., 500.),
|
||||
)));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.pending_scenario_reveal_visit.is_none());
|
||||
assert!(
|
||||
app.diagram_focus(crate::diagram_usage::Surface::Saved(diagram))
|
||||
.is_some()
|
||||
);
|
||||
assert_eq!(app.selected, message);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(30., 0.));
|
||||
let _ = app.update(Message::Delete);
|
||||
assert!(app.delete_pending);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
finish(&mut app, event, start + Vector::new(30., 0.));
|
||||
let _ = app.update(Message::CancelDelete);
|
||||
send(&mut app, event);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Controller acceptance: one indentation gesture is one draft transaction.
|
||||
use super::*;
|
||||
|
||||
fn fixture(source: &str) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
app.source_workspace = Workspace::new(
|
||||
&oa_core::source_project::SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: std::collections::BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||||
},
|
||||
true,
|
||||
);
|
||||
app.show_source_file(false);
|
||||
app
|
||||
}
|
||||
fn indent(app: &mut App, outdent: bool) {
|
||||
let message = Message::SourceIndent(app.source_symbol_context(), outdent);
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indentation_updates_draft_snapshot_version_and_single_undo_redo_without_model_change() {
|
||||
let source = "雨\r\n🚀\r\n";
|
||||
let mut app = fixture(source);
|
||||
app.source_editor.perform(text_editor::Action::SelectAll);
|
||||
let version = app.source_version;
|
||||
let revision = app.source_workspace.revision();
|
||||
let model = app.document.revision();
|
||||
indent(&mut app, false);
|
||||
let after = " 雨\r\n 🚀\r\n";
|
||||
assert_eq!(app.source_editor.text(), after);
|
||||
assert_eq!(app.source_snapshot.as_ref(), after);
|
||||
assert_eq!(app.source_workspace.files["project.arcadia"].text, after);
|
||||
assert!(app.source_workspace.has_edits());
|
||||
assert_eq!(app.source_version, version + 1);
|
||||
assert_eq!(app.source_workspace.revision(), revision + 1);
|
||||
assert_eq!(app.document.revision(), model);
|
||||
let _ = app.update(Message::SourceUndo);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert!(!app.source_workspace.has_edits());
|
||||
let _ = app.update(Message::SourceRedo);
|
||||
assert_eq!(app.source_editor.text(), after);
|
||||
assert_eq!(app.document.revision(), model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_hidden_and_modal_indentation_is_rejected_before_navigation_invalidation() {
|
||||
for rejected in 0..7 {
|
||||
let mut app = fixture("alpha");
|
||||
let mut context = app.source_symbol_context();
|
||||
match rejected {
|
||||
0 => context.path = "elsewhere.arcadia".into(),
|
||||
1 => context.session = Id::new_v4(),
|
||||
2 => app.page = Page::Home,
|
||||
3 => app.busy = true,
|
||||
4 => app.delete_pending = true,
|
||||
5 => app.hosted.visible = true,
|
||||
_ => app.pending = Some(Pending::New),
|
||||
}
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let version = app.source_version;
|
||||
let _ = app.update(Message::SourceIndent(context, false));
|
||||
assert_eq!(app.source_editor.text(), "alpha");
|
||||
assert_eq!(app.source_version, version);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_outdent_does_not_add_history_or_increment_source_version() {
|
||||
let mut app = fixture("alpha");
|
||||
let version = app.source_version;
|
||||
indent(&mut app, true);
|
||||
assert_eq!(app.source_version, version);
|
||||
assert!(!app.source_workspace.undo(false));
|
||||
}
|
||||
@@ -754,76 +754,7 @@ fn invalid_open_retains_accepted_document_but_refuses_alias_rename() {
|
||||
}
|
||||
|
||||
fn captured_escape(app: &App) -> Vec<Message> {
|
||||
use iced::advanced::{Layout, Shell, layout, subscription, widget};
|
||||
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
|
||||
use iced::keyboard::{self, key::Named};
|
||||
use std::task::{Context as PollContext, Poll};
|
||||
let mut input: iced::Element<'_, (), iced::Theme, ()> =
|
||||
iced::widget::text_input("Source alias", "retained_alias")
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(Named::Escape),
|
||||
modified_key: keyboard::Key::Named(Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut widget_messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut widget_messages);
|
||||
input.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
let event = subscription::Event::Interaction {
|
||||
window: iced::window::Id::unique(),
|
||||
event,
|
||||
status: iced::event::Status::Captured,
|
||||
};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut messages = Vec::new();
|
||||
let mut keyboard_boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
|
||||
let mut context = PollContext::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
|
||||
if matches!(
|
||||
&message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
keyboard_boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
keyboard_boundaries, 1,
|
||||
"each key has exactly one passive allocation notification"
|
||||
);
|
||||
messages
|
||||
crate::app::escape::tests::focused_input_messages(app)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,6 +33,9 @@ fn admission_notice(error: query::AdmissionError) -> String {
|
||||
impl App {
|
||||
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SourceIndent(context, _) => {
|
||||
context == &self.source_symbol_context() && self.source_navigation_available()
|
||||
}
|
||||
Message::SourceSymbols(context, event) => {
|
||||
context.session == self.source_workspace.session()
|
||||
&& context.path == self.source_workspace.active
|
||||
|
||||
@@ -864,99 +864,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
}
|
||||
|
||||
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
|
||||
use iced::advanced::{Layout, Shell, layout, subscription, widget};
|
||||
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
|
||||
use iced::keyboard::{self, key::Named};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
let mut input: iced::Element<'_, (), iced::Theme, ()> =
|
||||
iced::widget::text_input("Find / modal input", "Retain exact text 雨")
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
if focused {
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
}
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(Named::Escape),
|
||||
modified_key: keyboard::Key::Named(Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut input_messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut input_messages);
|
||||
input.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
let captured = shell.is_event_captured();
|
||||
assert_eq!(captured, focused);
|
||||
assert!(input_messages.is_empty());
|
||||
assert!(
|
||||
!tree
|
||||
.state
|
||||
.downcast_ref::<iced::widget::text_input::State<()>>()
|
||||
.is_focused()
|
||||
);
|
||||
let event = subscription::Event::Interaction {
|
||||
window: iced::window::Id::unique(),
|
||||
event,
|
||||
status: if captured {
|
||||
iced::event::Status::Captured
|
||||
} else {
|
||||
iced::event::Status::Ignored
|
||||
},
|
||||
};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut messages = Vec::new();
|
||||
let mut keyboard_boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
|
||||
let mut context = Context::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
|
||||
assert!(
|
||||
!matches!(message, Message::CheckpointTick),
|
||||
"timer must remain pending"
|
||||
);
|
||||
if matches!(
|
||||
&message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
keyboard_boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
keyboard_boundaries, 1,
|
||||
"each key has exactly one passive allocation notification"
|
||||
);
|
||||
messages
|
||||
crate::app::escape::tests::input_messages(app, iced::keyboard::key::Named::Escape, focused)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pending_work() {
|
||||
fn captured_find_and_modal_escape_use_one_actual_delivery_and_preserve_pending_work() {
|
||||
for pending in [false, true] {
|
||||
for modal in 0..4 {
|
||||
let mut app = fixture();
|
||||
@@ -1053,7 +965,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
|
||||
fn captured_delivery_does_not_duplicate_existing_behavior_or_architecture_routes() {
|
||||
let mut app = fixture();
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::References));
|
||||
let project = app.project().clone();
|
||||
|
||||
@@ -24,6 +24,7 @@ impl App {
|
||||
if self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
self.behavior.scenario.forget_view();
|
||||
self.layer = layer;
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(machine);
|
||||
|
||||
@@ -4,6 +4,8 @@ use super::*;
|
||||
mod attachment_escape;
|
||||
#[path = "state_bend_tests.rs"]
|
||||
mod bends;
|
||||
#[path = "state_picker_escape_tests.rs"]
|
||||
mod picker_escape;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The actual State workspace picker, App root and subscriptions.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{keyboard::key::Named, mouse};
|
||||
|
||||
async fn press_escape(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
||||
let input = key(Named::Escape);
|
||||
let (mut messages, statuses) = mounted.input_with_status(
|
||||
app,
|
||||
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);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
mounted.settle(app).await;
|
||||
messages
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_missing_picker_escape_preserves_exact_document_then_reopens_and_places() {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.retain(|_, node| node.element != f.b);
|
||||
let mut app = App::with_project(project);
|
||||
app.window_size = iced::Size::new(1800., 1100.);
|
||||
send(&mut app, Message::Select(f.a));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
let choice = Choice {
|
||||
id: diagram,
|
||||
label: app.project().diagrams[&diagram].name.clone(),
|
||||
};
|
||||
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]);
|
||||
// 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();
|
||||
let selection = (
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.diagram.placement,
|
||||
);
|
||||
let camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
);
|
||||
let revision = app.document.revision();
|
||||
let history = (app.document.can_undo(), app.document.can_redo());
|
||||
mounted.click(&mut app, picker).await;
|
||||
assert!(
|
||||
press_escape(&mut mounted, &mut app).await.is_empty(),
|
||||
"open picker owns Escape, not the State canvas or App"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert_eq!(
|
||||
(
|
||||
app.selected,
|
||||
app.selected_node,
|
||||
app.behavior.diagram.placement
|
||||
),
|
||||
selection
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit
|
||||
),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
|
||||
// If Escape left the menu open this click would close it, and the following
|
||||
// option click would fail to place. This checks actual overlay dismissal.
|
||||
mounted.click(&mut app, picker).await;
|
||||
let messages = mounted.click(&mut app, option).await;
|
||||
assert!(messages.iter().any(|message| matches!(message, Message::StateDiagram(Event::Place { element, .. }) if *element == f.b)));
|
||||
assert!(state_edit::unplaced(app.project(), diagram).is_empty());
|
||||
assert_eq!(app.project().elements, original.elements);
|
||||
assert_eq!(app.project().relations, original.relations);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
use crate::{context_menu::Entry, model_menu, views::traceability::Filter};
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Detect", None)
|
||||
.unwrap();
|
||||
let mixed = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability;
|
||||
p.place(mixed, oa, Position { x: 300., y: 200. });
|
||||
let system = p.ensure_diagram(Layer::Sa);
|
||||
p.place(system, sa, Position { x: 700., y: 200. });
|
||||
oa_core::validate_candidate(&p).unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(mixed);
|
||||
app.selected = oa;
|
||||
app.refresh_draft();
|
||||
app.traceability.filter = Filter::Artifacts;
|
||||
app.traceability.query = "stale artifact query".into();
|
||||
(app, mixed, system, oa, sa)
|
||||
}
|
||||
|
||||
fn show_action(app: &mut App, element: Id) -> Message {
|
||||
send(app, model_menu::open(element, iced::Point::ORIGIN));
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == "Show in this diagram" => {
|
||||
Some(message)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("eligible real menu action")
|
||||
.expect("eligible menu action must be enabled")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_layer_traces_resets_inventory_without_applying_property_or_source_drafts() {
|
||||
for source_draft in [false, true] {
|
||||
let (mut app, mixed, _, oa, _) = fixture();
|
||||
if source_draft {
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
)),
|
||||
);
|
||||
assert!(app.source_dirty());
|
||||
} else {
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/alias".into(), "invalid alias".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let source = app.source_editor.text();
|
||||
let before = app.document.snapshot();
|
||||
let camera = (app.zoom, app.pan);
|
||||
send(&mut app, Message::ReviewLayerTraces);
|
||||
assert_eq!(app.page, Page::Traces);
|
||||
assert_eq!(app.traceability.filter, Filter::Traces);
|
||||
assert!(app.traceability.query.is_empty());
|
||||
assert_eq!(
|
||||
(app.layer, app.active_diagram, app.selected),
|
||||
(Layer::Oa, Some(mixed), oa)
|
||||
);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_layer_traces_respects_busy_modal_and_active_placement_guards() {
|
||||
for already_traces in [false, true] {
|
||||
for guard in 0..5 {
|
||||
let (mut app, mixed, _, oa, _) = fixture();
|
||||
match guard {
|
||||
0 => app.busy = true,
|
||||
1 => app.delete_pending = true,
|
||||
2 => app.hosted.visible = true,
|
||||
3 => send(
|
||||
&mut app,
|
||||
Message::Architecture(architecture_authoring::Event::Existing(None)),
|
||||
),
|
||||
4 => {
|
||||
app.page = Page::Behavior;
|
||||
let p = app.project().clone();
|
||||
app.behavior
|
||||
.creation
|
||||
.update(&p, behavior::creation::Event::Open(Layer::Oa))
|
||||
.unwrap();
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
if already_traces {
|
||||
app.page = Page::Traces;
|
||||
}
|
||||
let original_page = app.page;
|
||||
let before = app.document.snapshot();
|
||||
let behavior_before = app.behavior.recovery_snapshot().unwrap();
|
||||
send(&mut app, Message::ReviewLayerTraces);
|
||||
assert_eq!(
|
||||
app.page, original_page,
|
||||
"guard {guard}, already traces {already_traces}"
|
||||
);
|
||||
assert_eq!(app.traceability.filter, Filter::Artifacts);
|
||||
assert_eq!(app.traceability.query, "stale artifact query");
|
||||
assert_eq!(
|
||||
(app.layer, app.active_diagram, app.selected),
|
||||
(Layer::Oa, Some(mixed), oa)
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), behavior_before);
|
||||
if guard == 3 {
|
||||
assert!(app.architecture.is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_view_show_keeps_its_context_for_every_diagram_app_and_target_layer() {
|
||||
for diagram_layer in [Layer::Oa, Layer::Sa] {
|
||||
for app_layer in [Layer::Oa, Layer::Sa] {
|
||||
for target_layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observe", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Detect", None)
|
||||
.unwrap();
|
||||
let target = if target_layer == Layer::Oa { oa } else { sa };
|
||||
let other = if target_layer == Layer::Oa { sa } else { oa };
|
||||
let mixed = p.ensure_diagram(diagram_layer);
|
||||
p.diagrams.get_mut(&mixed).unwrap().projection = ProjectionKind::Traceability;
|
||||
// An attractive same-layer alternative makes accidental
|
||||
// semantic-selection navigation observable in both directions.
|
||||
let mut alternative = p.diagrams[&mixed].clone();
|
||||
alternative.id = Id::new_v4();
|
||||
alternative.alias = "same_layer_alternative".into();
|
||||
alternative.layer = target_layer;
|
||||
alternative.projection = ProjectionKind::Overview;
|
||||
let alternative_id = alternative.id;
|
||||
p.diagrams.insert(alternative_id, alternative);
|
||||
p.place(alternative_id, target, Position { x: 700., y: 200. });
|
||||
oa_core::validate_candidate(&p).unwrap();
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = app_layer;
|
||||
app.active_diagram = Some(mixed);
|
||||
app.selected = other;
|
||||
app.refresh_draft();
|
||||
let before = app.project().clone();
|
||||
let action = show_action(&mut app, target);
|
||||
send(&mut app, action);
|
||||
// The existing RevealInDiagram action synchronizes the layer
|
||||
// to the captured view, even if the prior perspective differed.
|
||||
assert_eq!(
|
||||
(app.active_diagram, app.layer, app.selected),
|
||||
(Some(mixed), diagram_layer, target)
|
||||
);
|
||||
let added = app.project().diagrams[&mixed]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == target)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(app.selected_node, Some(added.id));
|
||||
let mut expected = before;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&mixed)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(added.id, added);
|
||||
assert_eq!(app.project(), &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_view_show_menu_places_in_captured_view_with_exact_undo_redo() {
|
||||
let (mut app, mixed, system, _, sa) = fixture();
|
||||
let before = app.document.snapshot();
|
||||
let action = show_action(&mut app, sa);
|
||||
send(&mut app, action);
|
||||
assert_eq!(
|
||||
(app.active_diagram, app.layer, app.selected),
|
||||
(Some(mixed), Layer::Oa, sa)
|
||||
);
|
||||
let added = app.project().diagrams[&mixed]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == sa)
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(added.position, Position { x: 60., y: 60. });
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&mixed)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(added.id, added);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&system],
|
||||
before.decoded.project.diagrams[&system]
|
||||
);
|
||||
let after = app.document.snapshot();
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &after.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_view_show_menu_rejects_stale_context_and_pending_property_changes() {
|
||||
for refusal in 0..3 {
|
||||
let (mut app, mixed, system, oa, sa) = fixture();
|
||||
let action = show_action(&mut app, sa);
|
||||
match refusal {
|
||||
0 => app.active_diagram = Some(system),
|
||||
1 => app.canvas_epoch = app.canvas_epoch.wrapping_add(1),
|
||||
2 => {
|
||||
// A queued action must not apply a draft that became dirty
|
||||
// after the menu was rendered, even when its context is valid.
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied name".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let before = app.document.snapshot();
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
send(&mut app, action);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, oa);
|
||||
assert_eq!(
|
||||
app.active_diagram,
|
||||
Some(if refusal == 0 { system } else { mixed })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
@@ -49,11 +49,28 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
|
||||
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
|
||||
p.place(view, reused, Position { x: 873., y: 421. });
|
||||
let mut app = App::with_project(p);
|
||||
app.layer = layer;
|
||||
app.page = Page::Behavior;
|
||||
app.selected = root;
|
||||
app.behavior.active_owner = Some(root);
|
||||
app.refresh_draft();
|
||||
let accepted = app.document.snapshot();
|
||||
// Enter through the actual navigation path: assigning Page/owner alone does
|
||||
// not create the representation and live visit required by canvas events.
|
||||
send(&mut app, Message::Select(root));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, root);
|
||||
assert_eq!(app.behavior.active_owner, Some(root));
|
||||
if kind == Kind::Scenario {
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.document, app.canvas_document);
|
||||
assert_eq!(context.scenario, root);
|
||||
assert_eq!(
|
||||
context.representation,
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner: root }
|
||||
);
|
||||
assert!(!context.visit.is_nil());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
(app, root, work, reused)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
mod scenario_activation;
|
||||
#[cfg(test)]
|
||||
mod scenario_hit_tests;
|
||||
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod scenario_layout;
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
@@ -59,9 +64,18 @@ mod scenario_scroll {
|
||||
process: bool,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
}
|
||||
type Report = Option<(bool, Id, Id, Vector)>;
|
||||
type Identity = (Id, Id, scenario_layout::Representation, Id);
|
||||
fn identity(context: scenario::Context) -> Identity {
|
||||
(
|
||||
context.document,
|
||||
context.scenario,
|
||||
context.representation,
|
||||
context.visit,
|
||||
)
|
||||
}
|
||||
type Report = Option<(bool, Identity, Vector)>;
|
||||
struct ObservedScroll {
|
||||
identity: (Id, Id),
|
||||
identity: Identity,
|
||||
report: Report,
|
||||
restored: Option<Id>,
|
||||
}
|
||||
@@ -112,8 +126,12 @@ mod scenario_scroll {
|
||||
) {
|
||||
let restore = self.restore.filter(|request| {
|
||||
!self.process
|
||||
&& (request.document, request.owner)
|
||||
== (self.context.document, self.context.scenario)
|
||||
&& (
|
||||
request.document,
|
||||
request.owner,
|
||||
request.representation,
|
||||
request.visit,
|
||||
) == identity(self.context)
|
||||
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
|
||||
});
|
||||
let receipt = restore.and_then(|request| {
|
||||
@@ -137,12 +155,7 @@ mod scenario_scroll {
|
||||
&mut offset,
|
||||
);
|
||||
if let Some(offset) = offset.0 {
|
||||
let next = Some((
|
||||
self.process,
|
||||
self.context.document,
|
||||
self.context.scenario,
|
||||
offset,
|
||||
));
|
||||
let next = Some((self.process, identity(self.context), offset));
|
||||
let state = tree.state.downcast_mut::<ObservedScroll>();
|
||||
if let Some(token) = receipt {
|
||||
state.restored = Some(token);
|
||||
@@ -186,7 +199,7 @@ mod scenario_scroll {
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(ObservedScroll {
|
||||
identity: (self.context.document, self.context.scenario),
|
||||
identity: identity(self.context),
|
||||
report: None,
|
||||
restored: None,
|
||||
})
|
||||
@@ -195,9 +208,7 @@ mod scenario_scroll {
|
||||
vec![widget::Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
if tree.state.downcast_ref::<ObservedScroll>().identity
|
||||
!= (self.context.document, self.context.scenario)
|
||||
{
|
||||
if tree.state.downcast_ref::<ObservedScroll>().identity != identity(self.context) {
|
||||
// A new owner/document needs fresh real scroll state, not merely
|
||||
// cleared mirrored offsets. Edits and tool/form changes retain it.
|
||||
tree.state = self.state();
|
||||
@@ -486,17 +497,25 @@ mod scenario_scroll {
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
|
||||
for mismatch in 0..3 {
|
||||
for mismatch in 0..5 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut request = scenario::ScrollRestore {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
visit: context.visit,
|
||||
token: Id::new_v4(),
|
||||
offset: Vector::new(0., 180.),
|
||||
};
|
||||
match mismatch {
|
||||
0 => request.document = Id::new_v4(),
|
||||
1 => request.owner = Id::new_v4(),
|
||||
3 => {
|
||||
request.representation = scenario_layout::Representation::Saved {
|
||||
diagram: Id::new_v4(),
|
||||
}
|
||||
}
|
||||
4 => request.visit = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
let mut view = observer(600., context);
|
||||
@@ -583,7 +602,7 @@ mod scenario_scroll {
|
||||
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
|
||||
for process in [false, true] {
|
||||
let area = scenario::ScrollArea::Page;
|
||||
for change in 0..4 {
|
||||
for change in 0..6 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut view = observer(600., context);
|
||||
view.process = process;
|
||||
@@ -612,6 +631,12 @@ mod scenario_scroll {
|
||||
next.revision += 1;
|
||||
next.epoch += 5;
|
||||
}
|
||||
4 => {
|
||||
next.representation = scenario_layout::Representation::Saved {
|
||||
diagram: Id::new_v4(),
|
||||
}
|
||||
}
|
||||
5 => next.visit = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
view = observer(if change == 3 { 800. } else { 600. }, next);
|
||||
@@ -659,7 +684,7 @@ mod scenario_scroll {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![if change < 2 {
|
||||
vec![if !(2..4).contains(&change) {
|
||||
Vector::ZERO
|
||||
} else {
|
||||
Vector::new(0., 180.)
|
||||
@@ -973,6 +998,19 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
// A requested saved view remains the destination even after its model is removed.
|
||||
// The shared display adapter reports unavailable instead of choosing another behavior.
|
||||
if matches!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(scenario_layout::Representation::Saved { .. })
|
||||
) && let Some(owner) = app.behavior.scenario.owner()
|
||||
&& !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer)
|
||||
{
|
||||
return scenario::view(app, owner, column![].spacing(6));
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -984,7 +1022,14 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
label: format!("{} · {}", e.kind(), e.name),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let owner = selected_owner(app);
|
||||
let owner = if matches!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(scenario_layout::Representation::Saved { .. })
|
||||
) {
|
||||
app.behavior.scenario.owner()
|
||||
} else {
|
||||
selected_owner(app)
|
||||
};
|
||||
let selected = owner
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.or(choices.first())
|
||||
@@ -1118,8 +1163,27 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Identifies the drawn primitive that won selection, not an authoring permission.
|
||||
/// In particular an activation and its message can share an occurrence UUID.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SceneHitRole {
|
||||
LifelineHeader,
|
||||
MessageBody,
|
||||
MessageLabel,
|
||||
Activation,
|
||||
LifecycleMarker,
|
||||
LifelineGuide,
|
||||
Box,
|
||||
OtherLine,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct SceneHit {
|
||||
id: Id,
|
||||
role: SceneHitRole,
|
||||
}
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
role: SceneHitRole,
|
||||
rect: Rectangle,
|
||||
title: String,
|
||||
detail: String,
|
||||
@@ -1152,6 +1216,7 @@ fn box_text_clip(
|
||||
|
||||
struct Line {
|
||||
id: Id,
|
||||
role: SceneHitRole,
|
||||
points: Vec<Point>,
|
||||
label: String,
|
||||
label_at: Point,
|
||||
@@ -1172,12 +1237,17 @@ struct Scene {
|
||||
boxes: Vec<BoxItem>,
|
||||
lines: Vec<Line>,
|
||||
size: Size,
|
||||
layout_origin: Point,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
spacing_stamp: Option<scenario::spacing::Stamp>,
|
||||
coordinate_input: bool,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
// Exact render coordinates include unassigned lanes; do not derive from cards.
|
||||
render_lanes: BTreeMap<Id, f32>,
|
||||
slots: Vec<SequenceSlot>,
|
||||
rows: BTreeMap<Id, (Id, f32)>,
|
||||
activations: Vec<scenario_activation::Segment>,
|
||||
@@ -1201,6 +1271,10 @@ struct SequenceInput {
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum SequencePress {
|
||||
Spacing {
|
||||
stamp: scenario::spacing::Stamp,
|
||||
token: Id,
|
||||
},
|
||||
Occurrence {
|
||||
tool: scenario::Tool,
|
||||
slot: scenario::Slot,
|
||||
@@ -1221,6 +1295,19 @@ enum SequencePress {
|
||||
},
|
||||
}
|
||||
impl SequenceInput {
|
||||
fn cancel_spacing(&mut self) -> Option<Action<Message>> {
|
||||
let cancel = match self.press {
|
||||
Some(SequencePress::Spacing { stamp, token }) => Some(
|
||||
Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing(
|
||||
scenario::spacing::Event::Cancel { stamp, token },
|
||||
)))
|
||||
.and_capture(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
self.cancel();
|
||||
cancel
|
||||
}
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
@@ -1232,17 +1319,31 @@ impl Scene {
|
||||
&& (self.boxes.iter().any(|item| item.id == id)
|
||||
|| self.lines.iter().any(|item| item.id == id))
|
||||
}
|
||||
// The integrated removal guard also needs the derived semantic hit set.
|
||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
Self::sequence_geometry(p, owner, selected, None, None)
|
||||
}
|
||||
fn sequence_geometry(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
selected: Id,
|
||||
lane_override: Option<&BTreeMap<Id, f32>>,
|
||||
plan: Option<&scenario_layout::Plan>,
|
||||
) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(700., 200.),
|
||||
layout_origin: Point::ORIGIN,
|
||||
selected,
|
||||
interaction: None,
|
||||
spacing_stamp: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
render_lanes: BTreeMap::new(),
|
||||
slots: vec![],
|
||||
rows: BTreeMap::new(),
|
||||
activations: vec![],
|
||||
@@ -1313,12 +1414,32 @@ impl Scene {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let lane_x = lanes
|
||||
let mut lane_x = lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if let Some(values) = lane_override {
|
||||
for (id, x) in values {
|
||||
if let Some(lane) = lane_x.get_mut(id) {
|
||||
*lane = *x;
|
||||
}
|
||||
}
|
||||
}
|
||||
scene.render_lanes = lane_x.clone();
|
||||
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
|
||||
let layout_left = if lane_override.is_some() {
|
||||
lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.))
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
scene.layout_origin.x = layout_left;
|
||||
if lane_override.is_some() {
|
||||
scene.size.width = lane_x
|
||||
.values()
|
||||
.fold(scene.size.width, |right, x| right.max(*x + 240.))
|
||||
- layout_left;
|
||||
}
|
||||
if scenario {
|
||||
scene.lanes = lane_x
|
||||
.iter()
|
||||
@@ -1334,8 +1455,26 @@ impl Scene {
|
||||
.collect();
|
||||
}
|
||||
let mut y = if scenario { 148. } else { 120. };
|
||||
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
|
||||
scene.size.height = (y + 45.).max(210.);
|
||||
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan);
|
||||
if layout_left != 0. {
|
||||
for item in &mut scene.boxes {
|
||||
let body = scenario_layout::occurrence(p, item.id);
|
||||
if matches!(
|
||||
body,
|
||||
Some(
|
||||
OccurrenceBody::Branch { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Control { .. }
|
||||
| OccurrenceBody::SequenceReference { .. }
|
||||
| OccurrenceBody::Exchange { .. }
|
||||
)
|
||||
) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. })))
|
||||
{
|
||||
item.rect.x += layout_left;
|
||||
}
|
||||
}
|
||||
}
|
||||
scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height);
|
||||
if scenario {
|
||||
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
|
||||
p,
|
||||
@@ -1346,6 +1485,9 @@ impl Scene {
|
||||
scene.size.height,
|
||||
);
|
||||
scene.lifecycle.align_messages(&mut scene.lines);
|
||||
if let Some(plan) = plan {
|
||||
plan.align_message_anchors(&mut scene.lines);
|
||||
}
|
||||
scene.layout_activations(p, &lane_x);
|
||||
}
|
||||
for (id, name, detail) in lanes {
|
||||
@@ -1355,6 +1497,7 @@ impl Scene {
|
||||
0,
|
||||
Line {
|
||||
id,
|
||||
role: SceneHitRole::LifelineGuide,
|
||||
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
|
||||
label: String::new(),
|
||||
label_at: Point::ORIGIN,
|
||||
@@ -1364,6 +1507,11 @@ impl Scene {
|
||||
}
|
||||
scene.boxes.push(BoxItem {
|
||||
id,
|
||||
role: if scenario {
|
||||
SceneHitRole::LifelineHeader
|
||||
} else {
|
||||
SceneHitRole::Box
|
||||
},
|
||||
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
|
||||
title: name,
|
||||
detail,
|
||||
@@ -1372,6 +1520,7 @@ impl Scene {
|
||||
}
|
||||
scene
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
|
||||
fn occurrences(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
@@ -1380,6 +1529,7 @@ impl Scene {
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
y: &mut f32,
|
||||
depth: usize,
|
||||
plan: Option<&scenario_layout::Plan>,
|
||||
) {
|
||||
let steps = p
|
||||
.elements
|
||||
@@ -1419,6 +1569,9 @@ impl Scene {
|
||||
}
|
||||
#[cfg(test)]
|
||||
scenario_lifecycle::render::apply_test_row_offset(*id, y);
|
||||
if let Some(plan) = plan {
|
||||
*y = plan.anchors[id].y;
|
||||
}
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1435,7 +1588,7 @@ impl Scene {
|
||||
OccurrenceBody::Fragment { operator, .. } => {
|
||||
let top = *y;
|
||||
*y += 55.;
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
|
||||
let rect = Rectangle::new(
|
||||
Point::new(18. + depth as f32 * 10., top),
|
||||
Size::new(
|
||||
@@ -1443,10 +1596,12 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
let rect = plan.map_or(rect, |plan| plan.frames[id]);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect,
|
||||
title: format!("{operator:?} · {}", e.name),
|
||||
detail: String::new(),
|
||||
@@ -1458,6 +1613,7 @@ impl Scene {
|
||||
OccurrenceBody::Branch { guard, .. } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(
|
||||
Point::new(36. + depth as f32 * 10., *y),
|
||||
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
|
||||
@@ -1474,7 +1630,7 @@ impl Scene {
|
||||
marker: Some(Kind::Occurrence),
|
||||
});
|
||||
*y += if scenario { 90. } else { 62. };
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
|
||||
}
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
@@ -1502,7 +1658,7 @@ impl Scene {
|
||||
source.and_then(|id| lanes.get(&id)),
|
||||
target.and_then(|id| lanes.get(&id)),
|
||||
) {
|
||||
let points = if a == b {
|
||||
let points = if source == target {
|
||||
vec![
|
||||
Point::new(*a, *y),
|
||||
Point::new(a + 75., *y),
|
||||
@@ -1541,6 +1697,7 @@ impl Scene {
|
||||
}
|
||||
self.lines.push(Line {
|
||||
id: *id,
|
||||
role: SceneHitRole::MessageBody,
|
||||
points,
|
||||
label: format!("{}. {kind}{}", index + 1, message_label),
|
||||
label_at: Point::new(a.min(*b) + 12., *y - 26.),
|
||||
@@ -1549,6 +1706,7 @@ impl Scene {
|
||||
} else {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(
|
||||
Point::new(65., *y - 20.),
|
||||
Size::new(self.size.width - 130., 65.),
|
||||
@@ -1571,6 +1729,7 @@ impl Scene {
|
||||
.unwrap_or(180.);
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
|
||||
title: e.name.clone(),
|
||||
detail: action
|
||||
@@ -1583,6 +1742,7 @@ impl Scene {
|
||||
OccurrenceBody::Control { kind } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
|
||||
title: format!("{kind:?} · {}", e.name),
|
||||
detail: "Contextual process control".into(),
|
||||
@@ -1593,6 +1753,7 @@ impl Scene {
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
|
||||
title: e.name.clone(),
|
||||
detail: sequence
|
||||
@@ -1605,6 +1766,7 @@ impl Scene {
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(
|
||||
Point::new(65., *y - 20.),
|
||||
Size::new(self.size.width - 130., 70.),
|
||||
@@ -1627,6 +1789,10 @@ impl Scene {
|
||||
}
|
||||
if ordered {
|
||||
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||
if let Some(plan) = plan {
|
||||
self.sequence_bounds.insert(owner, plan.scopes[&owner]);
|
||||
*y = plan.scopes[&owner].1 + 42.;
|
||||
}
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -1666,11 +1832,21 @@ impl Scene {
|
||||
))
|
||||
}
|
||||
fn hit(&self, point: Point) -> Option<Id> {
|
||||
self.hit_item(point).map(|hit| hit.id)
|
||||
}
|
||||
|
||||
/// Preserve the semantic selection order and tolerance while retaining the
|
||||
/// primitive's identity. Future spacing admission must also validate the
|
||||
/// saved view, owner and core occurrence kind; a hit alone grants no edit.
|
||||
fn hit_item(&self, point: Point) -> Option<SceneHit> {
|
||||
if !point.x.is_finite() || !point.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if let Some(id) = self.lifecycle.marker_at(point) {
|
||||
return Some(id);
|
||||
return Some(SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifecycleMarker,
|
||||
});
|
||||
}
|
||||
self.boxes
|
||||
.iter()
|
||||
@@ -1680,7 +1856,10 @@ impl Scene {
|
||||
&& b.rect.contains(point)
|
||||
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
|
||||
})
|
||||
.map(|b| b.id)
|
||||
.map(|b| SceneHit {
|
||||
id: b.id,
|
||||
role: b.role,
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
@@ -1690,26 +1869,51 @@ impl Scene {
|
||||
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
|
||||
})
|
||||
})
|
||||
.map(|line| line.id)
|
||||
.map(|line| SceneHit {
|
||||
id: line.id,
|
||||
role: line.role,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.activations
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.rect.contains(point))
|
||||
.map(|b| b.call)
|
||||
.map(|b| SceneHit {
|
||||
id: b.call,
|
||||
role: SceneHitRole::Activation,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
|
||||
.map(|line| line.id)
|
||||
.map(|line| SceneHit {
|
||||
id: line.id,
|
||||
role: if line.role == SceneHitRole::MessageBody {
|
||||
SceneHitRole::MessageLabel
|
||||
} else {
|
||||
SceneHitRole::OtherLine
|
||||
},
|
||||
})
|
||||
})
|
||||
// The generous message tolerance must not steal the exposed
|
||||
// shoulder of another call's execution when the view is zoomed out.
|
||||
.or_else(|| self.near_sequence_item(point))
|
||||
.or_else(|| self.lifecycle.lane_at(point, self.scale))
|
||||
.or_else(|| self.lane_at(point))
|
||||
.or_else(|| {
|
||||
self.lifecycle
|
||||
.lane_at(point, self.scale)
|
||||
.map(|id| SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifelineGuide,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lane_at(point).map(|id| SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifelineGuide,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
@@ -1720,11 +1924,14 @@ impl Scene {
|
||||
< 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|line| line.id)
|
||||
.map(|line| SceneHit {
|
||||
id: line.id,
|
||||
role: line.role,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn near_sequence_item(&self, point: Point) -> Option<Id> {
|
||||
fn near_sequence_item(&self, point: Point) -> Option<SceneHit> {
|
||||
if !self.scale.is_finite() || self.scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
@@ -1738,17 +1945,32 @@ impl Scene {
|
||||
.windows(2)
|
||||
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
(line.id, distance)
|
||||
(
|
||||
SceneHit {
|
||||
id: line.id,
|
||||
role: line.role,
|
||||
},
|
||||
distance,
|
||||
)
|
||||
});
|
||||
// Compare broad message and lifecycle-marker tolerance together after
|
||||
// exact shapes, so dense minimum-Fit rows choose the nearer item.
|
||||
self.lifecycle
|
||||
.nearest_marker(point)
|
||||
.map(|(id, distance)| {
|
||||
(
|
||||
SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifecycleMarker,
|
||||
},
|
||||
distance,
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.chain(messages)
|
||||
.filter(|(_, distance)| *distance <= 8. / self.scale)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
|
||||
.map(|(id, _)| id)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id)))
|
||||
.map(|(hit, _)| hit)
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for Scene {
|
||||
@@ -1776,15 +1998,33 @@ impl canvas::Program<Message> for Scene {
|
||||
// captured insertion slot belongs to the previous view, even if the
|
||||
// pointer itself did not move and the semantic context is unchanged.
|
||||
if state.bounds.is_some_and(|previous| previous != bounds) {
|
||||
state.cancel();
|
||||
let cancelled = state.cancel_spacing();
|
||||
state.pointer = None;
|
||||
state.bounds = Some(bounds);
|
||||
if cancelled.is_some() {
|
||||
return cancelled;
|
||||
}
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable {
|
||||
state.cancel();
|
||||
if (!editable || !self.coordinate_input)
|
||||
&& let Some(cancelled) = state.cancel_spacing()
|
||||
{
|
||||
return Some(cancelled);
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
|
||||
&& matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::CursorMoved { .. }
|
||||
| mouse::Event::ButtonReleased(mouse::Button::Left)
|
||||
)
|
||||
)
|
||||
{
|
||||
return state.cancel_spacing();
|
||||
}
|
||||
if !visible
|
||||
&& matches!(
|
||||
event,
|
||||
@@ -1803,7 +2043,7 @@ impl canvas::Program<Message> for Scene {
|
||||
| SequencePress::Reorder { start, moved, .. } => {
|
||||
*moved |= position.distance(*start) >= 3.
|
||||
}
|
||||
SequencePress::Occurrence { .. } => {}
|
||||
SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {}
|
||||
}
|
||||
}
|
||||
mouse::Cursor::Available(*position)
|
||||
@@ -1812,9 +2052,9 @@ impl canvas::Program<Message> for Scene {
|
||||
| canvas::Event::Window(iced::window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.cancel();
|
||||
let cancelled = state.cancel_spacing();
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
return cancelled.or_else(|| Some(Action::request_redraw()));
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
@@ -1850,8 +2090,9 @@ impl canvas::Program<Message> for Scene {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if state.source.is_some() || state.press.is_some() => {
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
return state
|
||||
.cancel_spacing()
|
||||
.or_else(|| Some(Action::request_redraw().and_capture()));
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
@@ -1871,6 +2112,14 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
if !self.coordinate_input {
|
||||
return self.hit(point).map(|id| {
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, id,
|
||||
)))
|
||||
.and_capture()
|
||||
});
|
||||
}
|
||||
if let CanvasTool::Message(_) = tool {
|
||||
let first = state.source.is_none();
|
||||
let (source, slot) = state.source.or_else(|| {
|
||||
@@ -1897,6 +2146,48 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Saved { .. }
|
||||
) {
|
||||
let hit = self.hit_item(point)?;
|
||||
let target = match hit.role {
|
||||
SceneHitRole::LifelineHeader => {
|
||||
Some(scenario::spacing::Target::Lane(hit.id))
|
||||
}
|
||||
SceneHitRole::MessageBody | SceneHitRole::MessageLabel => {
|
||||
Some(scenario::spacing::Target::Message(hit.id))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((target, stamp)) = target.zip(self.spacing_stamp)
|
||||
&& stamp.context == context
|
||||
{
|
||||
let token = Id::new_v4();
|
||||
state.press = Some(SequencePress::Spacing { stamp, token });
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
scenario::spacing::Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen: cursor.position()?,
|
||||
canvas_bounds: bounds,
|
||||
scale: self.scale,
|
||||
pan: self.camera_pan,
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
// Saved spacing never silently changes the shared Scenario order.
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, hit.id,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if let Some(occurrence) = self
|
||||
.hit(point)
|
||||
.filter(|id| self.rows.contains_key(id))
|
||||
@@ -1926,6 +2217,22 @@ impl canvas::Program<Message> for Scene {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
SequencePress::Spacing { stamp, token } => {
|
||||
state.source = None;
|
||||
let event = if world.is_some() {
|
||||
scenario::spacing::Event::Finish {
|
||||
stamp,
|
||||
token,
|
||||
screen: cursor.position()?,
|
||||
}
|
||||
} else {
|
||||
scenario::spacing::Event::Cancel { stamp, token }
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
SequencePress::Occurrence {
|
||||
tool: captured,
|
||||
slot,
|
||||
@@ -2017,6 +2324,18 @@ impl canvas::Program<Message> for Scene {
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
|
||||
if let Some(SequencePress::Spacing { stamp, token }) = state.press {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
scenario::spacing::Event::Move {
|
||||
stamp,
|
||||
token,
|
||||
screen: cursor.position()?,
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
_ => None,
|
||||
@@ -2198,7 +2517,10 @@ impl canvas::Program<Message> for Scene {
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable && state.context == Some(context) && state.tool == tool;
|
||||
let active = editable
|
||||
&& self.coordinate_input
|
||||
&& state.context == Some(context)
|
||||
&& state.tool == tool;
|
||||
let point = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
@@ -2214,7 +2536,12 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
});
|
||||
if editable
|
||||
&& self.coordinate_input
|
||||
&& tool == scenario::CanvasTool::Select
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
)
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
||||
|
||||
@@ -50,7 +50,7 @@ pub fn controls<'a>(
|
||||
control("Details", "Details", Some(details)),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
text(zoom_label(scale)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
]
|
||||
@@ -65,6 +65,7 @@ pub fn controls<'a>(
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
@@ -76,6 +77,7 @@ impl Scope {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
@@ -84,6 +86,7 @@ impl Scope {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
scenario_view: None,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
@@ -103,8 +106,8 @@ impl Scope {
|
||||
|
||||
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
|
||||
iced::advanced::widget::Id::from(format!(
|
||||
"behavior-control:{}:{}:{}:{name}",
|
||||
self.document, self.owner, self.revision
|
||||
"behavior-control:{}:{}:{}:{:?}:{name}",
|
||||
self.document, self.owner, self.revision, self.scenario_view
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -137,6 +140,20 @@ pub enum Event {
|
||||
pub use focus::{focus_task, root as focus_root};
|
||||
/// Fold relative input in event order. The base scale is used only on leaving Fit.
|
||||
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
|
||||
change_with_minimum(pan, zoom, fit, event, 0.05)
|
||||
}
|
||||
|
||||
/// Scenario extents can require scales below the topology canvas's 5% floor.
|
||||
pub(super) fn change_with_minimum(
|
||||
pan: &mut Vector,
|
||||
zoom: &mut Option<f32>,
|
||||
fit: &mut bool,
|
||||
event: Event,
|
||||
minimum: f32,
|
||||
) -> bool {
|
||||
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
|
||||
return false;
|
||||
}
|
||||
match event {
|
||||
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
let next = *pan + delta;
|
||||
@@ -156,7 +173,10 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
|
||||
&& at.y.is_finite() =>
|
||||
{
|
||||
let old = if *fit { base } else { zoom.unwrap_or(base) };
|
||||
let next = (old * factor).clamp(0.05, 3.);
|
||||
if !old.is_finite() || old <= 0. {
|
||||
return false;
|
||||
}
|
||||
let next = (old * factor).clamp(minimum, 3.);
|
||||
if old == next {
|
||||
return false;
|
||||
}
|
||||
@@ -174,6 +194,51 @@ pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: E
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom_label(scale: f32) -> String {
|
||||
let percent = f64::from(scale) * 100.;
|
||||
if !percent.is_finite() || percent <= 0. {
|
||||
return "—".into();
|
||||
}
|
||||
if percent >= 1. {
|
||||
format!("{percent:.0}%")
|
||||
} else if percent >= 0.01 {
|
||||
format!("{percent:.3}%")
|
||||
} else {
|
||||
format!("{percent:.2e}%")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod camera_policy_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn topology_retains_five_percent_floor() {
|
||||
let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false);
|
||||
assert!(change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fit,
|
||||
Event::Zoom {
|
||||
factor: 0.1,
|
||||
at: Point::ORIGIN,
|
||||
base: 0.06,
|
||||
}
|
||||
));
|
||||
assert_eq!(zoom, Some(0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() {
|
||||
assert_eq!(zoom_label(1.), "100%");
|
||||
assert_eq!(zoom_label(0.05), "5%");
|
||||
assert_eq!(zoom_label(0.00034193), "0.034%");
|
||||
assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%");
|
||||
assert_eq!(zoom_label(1e-7), "1.00e-5%");
|
||||
assert_ne!(zoom_label(f32::from_bits(1)), "0%");
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
|
||||
pub fn palette<'a>(
|
||||
scope: Scope,
|
||||
|
||||
@@ -379,6 +379,7 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input
|
||||
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
|
||||
use iced::widget::Space;
|
||||
let scope = Scope {
|
||||
scenario_view: None,
|
||||
document: Id::new_v4(),
|
||||
owner: Id::new_v4(),
|
||||
revision: 2,
|
||||
@@ -438,6 +439,7 @@ pub(crate) fn scope(app: &App) -> Scope {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
@@ -446,6 +448,7 @@ pub(crate) fn scope(app: &App) -> Scope {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
scenario_view: None,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
|
||||
@@ -893,11 +893,14 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Second scenario", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Oa;
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
// Capture the context created by actual navigation, including its visit.
|
||||
// Merely assigning active_owner/selected does not mount a Scenario anymore.
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.scenario, a);
|
||||
assert!(!context.visit.is_nil());
|
||||
let snapshot = app.document.snapshot();
|
||||
let source = app.document.bundle().clone();
|
||||
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
|
||||
context,
|
||||
@@ -908,23 +911,40 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
},
|
||||
lifeline: None,
|
||||
};
|
||||
app.behavior.active_owner = Some(b);
|
||||
app.selected = b;
|
||||
let _ = app.update(Message::Select(b));
|
||||
let second = app.scenario_context().unwrap();
|
||||
assert_eq!(second.scenario, b);
|
||||
assert_ne!(second.visit, context.visit);
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.project(), &p);
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
app.layer = Layer::Sa;
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
|
||||
let _ = app.update(Message::Select(a));
|
||||
let before_layer_change = app.scenario_context().unwrap();
|
||||
assert_eq!(before_layer_change.scenario, a);
|
||||
assert_ne!(before_layer_change.visit, context.visit);
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert!(app.scenario_context().is_none());
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
app.layer = Layer::Oa;
|
||||
|
||||
let _ = app.update(Message::Layer(Layer::Oa));
|
||||
let _ = app.update(Message::Select(a));
|
||||
let current = app.scenario_context().unwrap();
|
||||
assert_eq!(current.scenario, a);
|
||||
assert_ne!(current.visit, before_layer_change.visit);
|
||||
// Returning to the same owner/layer must not revive either retired packet.
|
||||
for retired in [context, before_layer_change] {
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(retired)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(current)));
|
||||
assert_eq!(app.behavior.pending_owner(), Some(a));
|
||||
assert_eq!(app.project(), &p);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,32 +10,6 @@ use serde::{Deserialize, Serialize};
|
||||
const PAGE_SIZE: usize = 6;
|
||||
pub const NAME_INPUT: &str = "behavior-creation-name";
|
||||
|
||||
// A focused text input consumes Escape. Preserve the application's modal
|
||||
// priority by emitting its ordinary Escape message from this narrow bridge.
|
||||
pub fn subscription(active: bool) -> iced::Subscription<()> {
|
||||
if active {
|
||||
iced::event::listen_with(captured_escape)
|
||||
} else {
|
||||
iced::Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_escape(
|
||||
event: iced::Event,
|
||||
status: iced::event::Status,
|
||||
_: iced::window::Id,
|
||||
) -> Option<()> {
|
||||
(status == iced::event::Status::Captured
|
||||
&& matches!(
|
||||
event,
|
||||
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Family {
|
||||
Sequence,
|
||||
|
||||
@@ -18,59 +18,11 @@ fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
|
||||
send(app, Event::Name(name.into()));
|
||||
}
|
||||
|
||||
fn focused_escape() -> Message {
|
||||
use iced::advanced::{Layout, Shell, layout, widget};
|
||||
use iced::keyboard::{self, key::Named};
|
||||
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
|
||||
.id(NAME_INPUT)
|
||||
.on_input(|_| ())
|
||||
.into();
|
||||
let mut tree = widget::Tree::new(input.as_widget());
|
||||
let size = iced::Size::new(300., 40.);
|
||||
let node =
|
||||
input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(Named::Escape),
|
||||
modified_key: keyboard::Key::Named(Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
input.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&(),
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
assert_eq!(
|
||||
captured_escape(
|
||||
event.clone(),
|
||||
iced::event::Status::Ignored,
|
||||
iced::window::Id::unique()
|
||||
),
|
||||
None
|
||||
);
|
||||
captured_escape(
|
||||
event,
|
||||
iced::event::Status::Captured,
|
||||
iced::window::Id::unique(),
|
||||
)
|
||||
.unwrap();
|
||||
Message::Escape
|
||||
fn focused_escape(app: &App) -> Message {
|
||||
let mut messages = crate::app::escape::tests::focused_input_messages(app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
messages.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -94,12 +46,12 @@ fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation()
|
||||
} else {
|
||||
app.pending = Some(Pending::Walkthrough);
|
||||
}
|
||||
let _ = app.update(focused_escape());
|
||||
let _ = app.update(focused_escape(&app));
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(focused_escape());
|
||||
let _ = app.update(focused_escape(&app));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo());
|
||||
|
||||
@@ -4,12 +4,28 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
presentation::{self, ScenarioPresentation},
|
||||
};
|
||||
#[cfg(test)]
|
||||
mod presentation_tests;
|
||||
mod usage;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
pub use workspace::{Shelf, view};
|
||||
|
||||
// A picker may select the Saved diagram record itself. Another view of the
|
||||
// same Scenario is not the property target of this displayed representation.
|
||||
pub(crate) fn properties_target(p: &Project, context: Context, id: Id) -> bool {
|
||||
super::selected_behavior(p, id) == Some(context.scenario)
|
||||
|| matches!(context.representation, Representation::Saved { diagram } if diagram == id)
|
||||
&& presentation::checked_owner(p, context.representation) == Some(context.scenario)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_operation_tests.rs"]
|
||||
mod operation_tests;
|
||||
@@ -99,6 +115,8 @@ pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool {
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub scenario: Id,
|
||||
pub representation: Representation,
|
||||
pub visit: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
@@ -125,6 +143,7 @@ impl std::fmt::Display for CanvasTool {
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CanvasEvent {
|
||||
Spacing(spacing::Event),
|
||||
Scrolled(Context, ScrollArea, iced::Vector),
|
||||
Restored(Context, Id, ScrollArea, iced::Vector),
|
||||
Viewport(Context, Size),
|
||||
@@ -132,9 +151,11 @@ pub enum CanvasEvent {
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::canvas_workspace::Event),
|
||||
Shelf(Context, Shelf),
|
||||
Properties(Context, Id),
|
||||
OpenUse(Context, Id),
|
||||
Back(Context),
|
||||
Choose(Context, CanvasTool),
|
||||
Representation(Context, Representation),
|
||||
Select(Context, Id),
|
||||
Remove(Context, Id),
|
||||
Begin {
|
||||
@@ -164,6 +185,7 @@ pub enum ScrollArea {
|
||||
impl CanvasEvent {
|
||||
pub fn context(&self) -> Context {
|
||||
match self {
|
||||
Self::Spacing(event) => event.stamp().context,
|
||||
Self::Scrolled(context, _, _)
|
||||
| Self::Restored(context, ..)
|
||||
| Self::Viewport(context, _)
|
||||
@@ -171,9 +193,11 @@ impl CanvasEvent {
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::Properties(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
| Self::Back(context)
|
||||
| Self::Choose(context, _)
|
||||
| Self::Representation(context, _)
|
||||
| Self::Select(context, _)
|
||||
| Self::Remove(context, _)
|
||||
| Self::Begin { context, .. }
|
||||
@@ -231,6 +255,7 @@ pub enum ValueSection {
|
||||
const VALUE_PAGE_SIZE: usize = 24;
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
spacing: Option<spacing::controller::Session>,
|
||||
draft: Option<Draft>,
|
||||
form_session: Id,
|
||||
form_mount: Id,
|
||||
@@ -246,6 +271,7 @@ const CALLER_LIMIT: usize = 32;
|
||||
pub(crate) struct Caller {
|
||||
document: Id,
|
||||
pub owner: Id,
|
||||
pub representation: Representation,
|
||||
pub occurrence: Id,
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
@@ -255,6 +281,8 @@ pub(crate) struct Caller {
|
||||
struct DetailsRestore {
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
visit: Id,
|
||||
token: Id,
|
||||
offset: iced::Vector,
|
||||
}
|
||||
@@ -262,6 +290,8 @@ struct DetailsRestore {
|
||||
pub(super) struct ScrollRestore {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub representation: Representation,
|
||||
pub visit: Id,
|
||||
pub token: Id,
|
||||
pub offset: iced::Vector,
|
||||
}
|
||||
@@ -354,46 +384,158 @@ impl State {
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.enter_representation(document, owner, Representation::Derived { owner });
|
||||
}
|
||||
/// App validates requested identity with checked_owner before entering.
|
||||
pub(crate) fn enter_representation(
|
||||
&mut self,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
) -> bool {
|
||||
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let changed = self.presentation.identity != Some((document, owner))
|
||||
|| self.presentation.representation != Some(representation);
|
||||
if changed {
|
||||
self.presentation = workspace::Presentation {
|
||||
identity: Some((document, owner)),
|
||||
representation: Some(representation),
|
||||
fit: matches!(representation, Representation::Saved { .. }),
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.restore = None;
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
}
|
||||
if changed || self.presentation.visit.is_nil() {
|
||||
self.spacing = None;
|
||||
self.presentation.visit = Id::new_v4();
|
||||
self.restore = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Suspend captured events while preserving this destination's camera state.
|
||||
pub(crate) fn leave(&mut self) {
|
||||
self.spacing = None;
|
||||
self.presentation.visit = Id::nil();
|
||||
self.restore = None;
|
||||
}
|
||||
pub(crate) fn forget_view(&mut self) {
|
||||
self.spacing = None;
|
||||
self.presentation.identity = None;
|
||||
self.presentation.representation = None;
|
||||
self.presentation.visit = Id::nil();
|
||||
self.restore = None;
|
||||
}
|
||||
pub(crate) fn representation(&self) -> Option<Representation> {
|
||||
self.presentation.representation
|
||||
}
|
||||
pub(crate) fn owner(&self) -> Option<Id> {
|
||||
self.presentation.identity.map(|(_, owner)| owner)
|
||||
}
|
||||
pub(crate) fn context(
|
||||
&self,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
revision: u64,
|
||||
epoch: u64,
|
||||
) -> Option<Context> {
|
||||
if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Context {
|
||||
document,
|
||||
scenario: owner,
|
||||
representation: self.representation()?,
|
||||
visit: self.presentation.visit,
|
||||
revision,
|
||||
epoch,
|
||||
})
|
||||
}
|
||||
pub(crate) fn display(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
selected: Id,
|
||||
) -> Option<ScenarioPresentation> {
|
||||
let (_, current_owner) = self.presentation.identity?;
|
||||
let representation = self.representation()?;
|
||||
(current_owner == owner).then(|| presentation::present(p, representation, selected))
|
||||
}
|
||||
pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool {
|
||||
self.context(
|
||||
context.document,
|
||||
context.scenario,
|
||||
context.revision,
|
||||
context.epoch,
|
||||
) == Some(context)
|
||||
&& self
|
||||
.display(p, context.scenario, context.scenario)
|
||||
.is_some_and(|shown| shown.capabilities().coordinate_input)
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
|
||||
pub(crate) fn camera(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
event: super::canvas_workspace::Event,
|
||||
) -> bool {
|
||||
let Some(bounds) = self
|
||||
.display(project, owner, owner)
|
||||
.and_then(|shown| shown.bounds())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let p = &mut self.presentation;
|
||||
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
|
||||
camera::change(
|
||||
&mut p.pan,
|
||||
&mut p.zoom,
|
||||
&mut p.fit,
|
||||
p.viewport,
|
||||
bounds,
|
||||
event,
|
||||
)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
||||
let size = Scene::sequence(p, owner, owner).size;
|
||||
let old = self.presentation.scale(size);
|
||||
let Some(bounds) = self
|
||||
.display(p, owner, owner)
|
||||
.and_then(|shown| shown.bounds())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if let Some(next) = zoom {
|
||||
let current = self.presentation.camera(bounds);
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: old,
|
||||
})
|
||||
self.camera(
|
||||
p,
|
||||
owner,
|
||||
super::canvas_workspace::Event::Zoom {
|
||||
factor: next / current.scale,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: current.scale,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
self.zoom(None);
|
||||
let fitted = self.presentation.scale(size);
|
||||
self.zoom(Some(fitted));
|
||||
self.presentation.pan = iced::Vector::ZERO;
|
||||
true
|
||||
self.zoom(None)
|
||||
}
|
||||
}
|
||||
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
|
||||
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
|
||||
}
|
||||
pub(crate) fn show_properties(&mut self) {
|
||||
// Panels remain available. Dismiss the competing outline without
|
||||
// replacing this view or its camera.
|
||||
self.presentation.shelf = None;
|
||||
}
|
||||
pub(crate) fn open_use(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
@@ -410,6 +552,7 @@ impl State {
|
||||
self.return_to.push(Caller {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
occurrence,
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
@@ -424,6 +567,7 @@ impl State {
|
||||
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
|
||||
frame.document == context.document
|
||||
&& frame.target == context.scenario
|
||||
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
|
||||
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
|
||||
}
|
||||
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
|
||||
@@ -440,16 +584,23 @@ impl State {
|
||||
None
|
||||
}
|
||||
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
|
||||
if context.document != frame.document || context.scenario != frame.owner {
|
||||
if context.document != frame.document
|
||||
|| context.scenario != frame.owner
|
||||
|| context.representation != frame.representation
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.details_offset = frame.details_offset;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.presentation.representation = Some(frame.representation);
|
||||
self.presentation.visit = context.visit;
|
||||
self.restore = Some(DetailsRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
representation: frame.representation,
|
||||
visit: context.visit,
|
||||
token: Id::new_v4(),
|
||||
offset: frame.details_offset,
|
||||
});
|
||||
@@ -459,13 +610,17 @@ impl State {
|
||||
context: Context,
|
||||
ScrollArea::Page: ScrollArea,
|
||||
) -> Option<ScrollRestore> {
|
||||
let restore = self
|
||||
.restore
|
||||
.as_ref()
|
||||
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
|
||||
let restore = self.restore.as_ref().filter(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.representation == context.representation
|
||||
&& r.visit == context.visit
|
||||
})?;
|
||||
Some(ScrollRestore {
|
||||
document: restore.document,
|
||||
owner: restore.owner,
|
||||
representation: restore.representation,
|
||||
visit: restore.visit,
|
||||
token: restore.token,
|
||||
offset: restore.offset,
|
||||
})
|
||||
@@ -478,7 +633,11 @@ impl State {
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !self.restore.as_ref().is_some_and(|r| {
|
||||
r.document == context.document && r.owner == context.scenario && r.token == token
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.representation == context.representation
|
||||
&& r.visit == context.visit
|
||||
&& r.token == token
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
@@ -1951,6 +2110,8 @@ pub(crate) mod direct_tests {
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
scenario: owner,
|
||||
representation: Representation::Derived { owner },
|
||||
visit: Id::new_v4(),
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Scenario camera policy. Bounds are from the exact displayed presentation,
|
||||
//! including its origin and automatic fallback; no model geometry is authored.
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Camera {
|
||||
pub(super) scale: f32,
|
||||
pub(super) pan: Vector,
|
||||
}
|
||||
|
||||
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
|
||||
/// and size cannot overflow while finding the center. Unrepresentable cameras
|
||||
/// are refused rather than silently clamped to an unrelated minimum scale.
|
||||
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
|
||||
if [
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
]
|
||||
.iter()
|
||||
.any(|n| !n.is_finite())
|
||||
|| bounds.width < 0.
|
||||
|| bounds.height < 0.
|
||||
|| viewport.width <= 0.
|
||||
|| viewport.height <= 0.
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let width = f64::from(viewport.width);
|
||||
let height = f64::from(viewport.height);
|
||||
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
|
||||
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
|
||||
.min(1.) as f32;
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
|
||||
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
|
||||
let pan = Vector::new(x as f32, y as f32);
|
||||
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
|
||||
}
|
||||
|
||||
pub(super) fn effective_camera(
|
||||
pan: Vector,
|
||||
zoom: Option<f32>,
|
||||
fitting: bool,
|
||||
viewport: Option<Size>,
|
||||
bounds: Rectangle,
|
||||
) -> Camera {
|
||||
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
|
||||
return camera;
|
||||
}
|
||||
Camera {
|
||||
scale: zoom.unwrap_or(1.),
|
||||
pan,
|
||||
}
|
||||
}
|
||||
|
||||
/// None preserves Fit as a mode, including before the first viewport arrives.
|
||||
pub(super) fn set_zoom(
|
||||
pan: &mut Vector,
|
||||
zoom: &mut Option<f32>,
|
||||
fitting: &mut bool,
|
||||
scale: Option<f32>,
|
||||
) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.min(3.));
|
||||
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
|
||||
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
|
||||
*zoom = next;
|
||||
*fitting = scale.is_none();
|
||||
*pan = next_pan;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Materialize the visible fitted camera only when a camera gesture succeeds.
|
||||
/// A zero-motion or rejected event preserves the original mode and fields.
|
||||
pub(super) fn change(
|
||||
pan: &mut Vector,
|
||||
zoom: &mut Option<f32>,
|
||||
fitting: &mut bool,
|
||||
viewport: Option<Size>,
|
||||
bounds: Rectangle,
|
||||
event: Event,
|
||||
) -> bool {
|
||||
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
|
||||
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
|
||||
let event = match event {
|
||||
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
|
||||
Event::Zoom { factor, at, .. } => Event::Zoom {
|
||||
factor,
|
||||
at,
|
||||
base: camera.scale,
|
||||
},
|
||||
event => event,
|
||||
};
|
||||
if !canvas_workspace::change_with_minimum(
|
||||
&mut next_pan,
|
||||
&mut next_zoom,
|
||||
&mut next_fit,
|
||||
event,
|
||||
MIN_SCALE,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
*pan = next_pan;
|
||||
*zoom = next_zoom;
|
||||
*fitting = next_fit;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::Point;
|
||||
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle {
|
||||
x: -1_000_160.,
|
||||
y: -200.,
|
||||
width: 2_000_400.,
|
||||
height: 1_000.,
|
||||
}
|
||||
}
|
||||
fn near(actual: f32, expected: f32, tolerance: f32) {
|
||||
assert!(
|
||||
(actual - expected).abs() <= tolerance,
|
||||
"{actual} != {expected}"
|
||||
);
|
||||
}
|
||||
fn projected(camera: Camera, point: Point) -> Point {
|
||||
Point::new(
|
||||
point.x * camera.scale + camera.pan.x,
|
||||
point.y * camera.scale + camera.pan.y,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_origin_fit_contains_independently_known_extremes() {
|
||||
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
|
||||
near(camera.scale, 0.0003419316, 1e-10);
|
||||
near(camera.pan.x, 349.98633, 0.0001);
|
||||
near(
|
||||
projected(camera, Point::new(-1_000_160., -200.)).x,
|
||||
8.,
|
||||
0.0001,
|
||||
);
|
||||
near(
|
||||
projected(camera, Point::new(1_000_240., 800.)).x,
|
||||
692.,
|
||||
0.0001,
|
||||
);
|
||||
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_remains_active_across_first_and_changed_viewport() {
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
|
||||
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
|
||||
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
|
||||
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
|
||||
assert!(actual.scale < retained.scale);
|
||||
near(
|
||||
projected(actual, Point::new(-1_000_160., -200.)).x,
|
||||
8.,
|
||||
0.0001,
|
||||
);
|
||||
near(
|
||||
projected(actual, Point::new(1_000_240., 800.)).x,
|
||||
492.,
|
||||
0.0001,
|
||||
);
|
||||
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
|
||||
let size = Size::new(700., 500.);
|
||||
let viewport = Some(size);
|
||||
let start = fit(bounds(), size).unwrap();
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
||||
let at = Point::new(210., 180.);
|
||||
let world = Point::new(
|
||||
(at.x - start.pan.x) / start.scale,
|
||||
(at.y - start.pan.y) / start.scale,
|
||||
);
|
||||
for factor in [1.25, 0.8] {
|
||||
let prior_scale = zoom.unwrap_or(start.scale);
|
||||
assert!(change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
viewport,
|
||||
bounds(),
|
||||
Event::Zoom {
|
||||
factor,
|
||||
at,
|
||||
base: start.scale,
|
||||
}
|
||||
));
|
||||
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
|
||||
near(camera.scale, prior_scale * factor, 1e-10);
|
||||
assert!(camera.scale < 0.001);
|
||||
near(projected(camera, world).x, at.x, 0.0001);
|
||||
near(projected(camera, world).y, at.y, 0.0001);
|
||||
}
|
||||
near(zoom.unwrap(), start.scale, 1e-10);
|
||||
near(pan.x, start.pan.x, 0.0001);
|
||||
assert!(!fitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
|
||||
let size = Size::new(700., 500.);
|
||||
let viewport = Some(size);
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
||||
for event in [
|
||||
Event::Pan(Vector::ZERO),
|
||||
Event::Zoom {
|
||||
factor: 1.,
|
||||
at: Point::ORIGIN,
|
||||
base: 1.,
|
||||
},
|
||||
] {
|
||||
assert!(!change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
viewport,
|
||||
bounds(),
|
||||
event
|
||||
));
|
||||
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
||||
}
|
||||
let start = fit(bounds(), size).unwrap();
|
||||
assert!(change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
viewport,
|
||||
bounds(),
|
||||
Event::Pan(Vector::new(20., -30.))
|
||||
));
|
||||
assert_eq!(pan, start.pan + Vector::new(20., -30.));
|
||||
assert_eq!(zoom, Some(start.scale));
|
||||
assert!(!fitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
|
||||
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
|
||||
let extreme = Rectangle {
|
||||
x: f32::MAX,
|
||||
y: 0.,
|
||||
width: f32::MAX,
|
||||
height: 100.,
|
||||
};
|
||||
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
|
||||
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
||||
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
|
||||
assert!(!change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
Some(Size::new(700., 500.)),
|
||||
bounds(),
|
||||
Event::Zoom {
|
||||
factor: 1.25,
|
||||
at: Point::ORIGIN,
|
||||
base: f32::NAN
|
||||
}
|
||||
));
|
||||
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use std::collections::BTreeMap;
|
||||
pub(crate) struct Stamp {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
representation: super::Representation,
|
||||
visit: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
@@ -20,6 +22,8 @@ impl Stamp {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
visit: context.visit,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
@@ -28,10 +32,12 @@ impl Stamp {
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
"{}:{}:{}:{:?}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.representation,
|
||||
stamp.visit,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Controller identity and actual presentation input, independent of App routing.
|
||||
use super::*;
|
||||
use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout};
|
||||
|
||||
fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("saved_{}", p.diagrams.len()),
|
||||
name: "Saved scenario".into(),
|
||||
description: String::new(),
|
||||
layer: p.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: lanes
|
||||
.iter()
|
||||
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
|
||||
.collect(),
|
||||
messages: vec![],
|
||||
})),
|
||||
},
|
||||
);
|
||||
let errors: Vec<_> = oa_core::validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
Representation::Saved { diagram: id }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() {
|
||||
let mut f = direct_tests::fixture();
|
||||
let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]);
|
||||
let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]);
|
||||
let original = f.project.clone();
|
||||
let document = Id::new_v4();
|
||||
let mut state = State::default();
|
||||
assert!(state.enter_representation(document, f.owner, a));
|
||||
let first = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert!(!state.enter_representation(document, f.owner, a));
|
||||
assert_eq!(state.context(document, f.owner, 7, 9), Some(first));
|
||||
state.presentation.pan = iced::Vector::new(23., -17.);
|
||||
state.leave();
|
||||
assert_eq!(state.representation(), Some(a));
|
||||
assert_eq!(state.owner(), Some(f.owner));
|
||||
assert!(state.context(document, f.owner, 7, 9).is_none());
|
||||
assert!(state.enter_representation(document, f.owner, a));
|
||||
let returned = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert_ne!(returned.visit, first.visit);
|
||||
assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.));
|
||||
assert!(state.enter_representation(document, f.owner, b));
|
||||
let middle = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert!(state.enter_representation(document, f.owner, a));
|
||||
let last = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert_ne!(last.visit, returned.visit);
|
||||
assert_ne!(last.visit, middle.visit);
|
||||
assert!(!state.coordinate_input(&f.project, first));
|
||||
assert!(state.coordinate_input(&f.project, last));
|
||||
let session = Id::new_v4();
|
||||
let mount = Id::new_v4();
|
||||
assert_ne!(
|
||||
focus::Stamp::new(first, session, mount),
|
||||
focus::Stamp::new(last, session, mount)
|
||||
);
|
||||
assert_eq!(f.project, original);
|
||||
state.forget_view();
|
||||
assert_eq!(state.representation(), None);
|
||||
assert_eq!(state.owner(), None);
|
||||
assert!(state.context(document, f.owner, 7, 9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() {
|
||||
let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture();
|
||||
let view = saved(&mut project, a, &[]);
|
||||
let original = project.clone();
|
||||
let document = Id::new_v4();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(document, a, view);
|
||||
let initial = state.context(document, a, 0, 0).unwrap();
|
||||
state.presentation.pan = iced::Vector::new(11., 29.);
|
||||
state.details_offset = iced::Vector::new(0., 71.);
|
||||
assert_eq!(state.open_use(&project, initial, reference).unwrap(), b);
|
||||
state.enter(document, b);
|
||||
let callee = state.context(document, b, 0, 0).unwrap();
|
||||
let frame = state.back(&project, callee).unwrap();
|
||||
assert_eq!(frame.representation, view);
|
||||
state.enter_representation(document, a, frame.representation);
|
||||
let returned = state.context(document, a, 0, 0).unwrap();
|
||||
state.restore_caller(returned, frame);
|
||||
assert_ne!(returned.visit, initial.visit);
|
||||
assert_eq!(state.representation(), Some(view));
|
||||
assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.));
|
||||
assert!(state.scroll_restore(initial, ScrollArea::Page).is_none());
|
||||
assert_eq!(
|
||||
state
|
||||
.scroll_restore(returned, ScrollArea::Page)
|
||||
.unwrap()
|
||||
.offset,
|
||||
iced::Vector::new(0., 71.)
|
||||
);
|
||||
assert_eq!(project, original);
|
||||
state.open_use(&project, returned, reference).unwrap();
|
||||
state.enter(document, b);
|
||||
let Representation::Saved { diagram } = view else {
|
||||
unreachable!()
|
||||
};
|
||||
project.diagrams.remove(&diagram);
|
||||
let callee = state.context(document, b, 0, 0).unwrap();
|
||||
assert!(state.back(&project, callee).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() {
|
||||
let mut f = direct_tests::fixture();
|
||||
// Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call.
|
||||
let rep = saved(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
&[(f.sender, 900.), (f.receiver, 900.)],
|
||||
);
|
||||
let original = f.project.clone();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(Id::new_v4(), f.owner, rep);
|
||||
let document = state.presentation.identity.unwrap().0;
|
||||
let context = state.context(document, f.owner, 0, 0).unwrap();
|
||||
let shown = state.display(&f.project, f.owner, f.call).unwrap();
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
presentation::PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert!(!state.coordinate_input(&f.project, context));
|
||||
let automatic = presentation::present(
|
||||
&f.project,
|
||||
Representation::Derived { owner: f.owner },
|
||||
f.call,
|
||||
);
|
||||
let automatic = automatic.scene().unwrap();
|
||||
assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes);
|
||||
assert_eq!(shown.scene().unwrap().activations, automatic.activations);
|
||||
assert!(!automatic.activations.is_empty());
|
||||
let mut scene = shown.into_scene().unwrap();
|
||||
scene.coordinate_input = false;
|
||||
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
|
||||
let point = Point::new(
|
||||
(line.points[0].x + line.points.last().unwrap().x) / 2.,
|
||||
line.points[0].y,
|
||||
);
|
||||
assert_eq!(scene.hit(point), Some(f.call));
|
||||
let bounds = Rectangle::with_size(Size::new(1600., 1000.));
|
||||
for tool in [
|
||||
CanvasTool::Select,
|
||||
CanvasTool::Message(MessageStyle::CallReply),
|
||||
CanvasTool::Add(Tool::Reuse),
|
||||
] {
|
||||
scene.interaction = Some((context, tool, true));
|
||||
let mut input = SequenceInput::default();
|
||||
let mut messages = vec![];
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved {
|
||||
position: Point::new(point.x, point.y + 100.),
|
||||
},
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
if let Some(message) = canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
{
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(
|
||||
matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call)
|
||||
);
|
||||
assert!(input.press.is_none() && input.source.is_none());
|
||||
}
|
||||
// Capture from an actual Ready presentation, then revoke coordinate input
|
||||
// with the complete fallback frame of the same Saved representation.
|
||||
let Representation::Saved { diagram } = rep else {
|
||||
unreachable!()
|
||||
};
|
||||
let mut ready_project = f.project.clone();
|
||||
ready_project.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let ready_original = ready_project.clone();
|
||||
let ready = state.display(&ready_project, f.owner, f.call).unwrap();
|
||||
assert!(matches!(
|
||||
ready.status(),
|
||||
presentation::PresentationStatus::Ready
|
||||
));
|
||||
let mut ready_scene = ready.into_scene().unwrap();
|
||||
let line = ready_scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == f.call)
|
||||
.unwrap();
|
||||
let ready_point = Point::new(
|
||||
(line.points[0].x + line.points.last().unwrap().x) / 2.,
|
||||
line.points[0].y,
|
||||
);
|
||||
let hit = ready_scene.hit_item(ready_point).unwrap();
|
||||
assert_eq!(hit.id, f.call);
|
||||
assert_eq!(hit.role, SceneHitRole::MessageBody);
|
||||
let stamp = spacing::Stamp {
|
||||
context,
|
||||
camera_epoch: 17,
|
||||
};
|
||||
ready_scene.coordinate_input = true;
|
||||
ready_scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
ready_scene.spacing_stamp = Some(stamp);
|
||||
let mut input = SequenceInput::default();
|
||||
let begin = canvas::Program::update(
|
||||
&ready_scene,
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(ready_point),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
.unwrap();
|
||||
let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin {
|
||||
stamp: captured_stamp,
|
||||
token,
|
||||
target: spacing::Target::Message(target),
|
||||
..
|
||||
})) = begin
|
||||
else {
|
||||
panic!("Ready Saved message must begin a stamped spacing gesture");
|
||||
};
|
||||
assert_eq!(captured_stamp, stamp);
|
||||
assert_eq!(target, f.call);
|
||||
assert!(matches!(input.press, Some(SequencePress::Spacing {
|
||||
stamp: held_stamp, token: held_token
|
||||
}) if held_stamp == stamp && held_token == token));
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
assert!(!scene.coordinate_input && scene.spacing_stamp.is_none());
|
||||
let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
|
||||
let result = canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&release,
|
||||
bounds,
|
||||
mouse::Cursor::Available(ready_point),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(
|
||||
matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token }
|
||||
))) if cancelled_stamp == stamp && cancelled_token == token)
|
||||
);
|
||||
assert!(input.press.is_none() && input.source.is_none());
|
||||
assert!(
|
||||
canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&release,
|
||||
bounds,
|
||||
mouse::Cursor::Available(ready_point),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(ready_project, ready_original);
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() {
|
||||
let mut f = direct_tests::fixture();
|
||||
let rep = saved(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
&[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)],
|
||||
);
|
||||
let original = f.project.clone();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(Id::new_v4(), f.owner, rep);
|
||||
state.viewport(Size::new(700., 500.));
|
||||
assert!(
|
||||
state.presentation.fit,
|
||||
"new saved views await their real viewport in Fit mode"
|
||||
);
|
||||
assert!(
|
||||
!state.camera_zoom(&f.project, f.owner, None),
|
||||
"already-active Fit is idempotent"
|
||||
);
|
||||
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
presentation::PresentationStatus::Ready
|
||||
));
|
||||
let bounds = shown.bounds().unwrap();
|
||||
assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.);
|
||||
let fitted = state.presentation.camera(bounds);
|
||||
assert!(fitted.scale > 0. && fitted.scale < 0.001);
|
||||
let left = bounds.x * fitted.scale + fitted.pan.x;
|
||||
let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x;
|
||||
assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001);
|
||||
assert!(state.camera(
|
||||
&f.project,
|
||||
f.owner,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.))
|
||||
));
|
||||
let moved = state.presentation.camera(bounds);
|
||||
assert_eq!(moved.scale, fitted.scale);
|
||||
assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.));
|
||||
assert!(!state.presentation.fit);
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() {
|
||||
let mut f = direct_tests::fixture();
|
||||
let rep = saved(&mut f.project, f.owner, &[]);
|
||||
let mut state = State::default();
|
||||
let document = Id::new_v4();
|
||||
state.enter_representation(document, f.owner, rep);
|
||||
let Representation::Saved { diagram } = rep else {
|
||||
unreachable!()
|
||||
};
|
||||
f.project.diagrams.remove(&diagram);
|
||||
let original = f.project.clone();
|
||||
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(shown.requested(), rep);
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
presentation::PresentationStatus::Unavailable { .. }
|
||||
));
|
||||
assert!(shown.scene().is_none() && shown.bounds().is_none());
|
||||
assert_eq!(state.owner(), Some(f.owner));
|
||||
assert!(state.context(document, f.owner, 0, 0).is_some());
|
||||
assert!(!state.camera_zoom(&f.project, f.owner, None));
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
//! Pure Saved-only spacing preparation. Delta is measured from the original
|
||||
//! pointer in the captured camera's world coordinates, never from the last
|
||||
//! preview. The controller must own document/revision/visit, camera and hit-role
|
||||
//! admission; this module neither dispatches commands nor changes selection.
|
||||
use crate::behavior::scenario_layout::{
|
||||
GeometryIssue, Representation, ResolvedScenarioPlan, presentation::checked_owner, resolve,
|
||||
};
|
||||
use oa_core::{
|
||||
Command, Id, Kind, OccurrenceBody, OccurrenceEndpoint, Project, ScenarioLanePosition,
|
||||
ScenarioMessagePosition, SharedBody, execute_batch,
|
||||
};
|
||||
|
||||
pub(crate) mod controller;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) use controller::{Event, Stamp};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
Lane(Id),
|
||||
Message(Id),
|
||||
}
|
||||
impl Target {
|
||||
pub(crate) fn id(self) -> Id {
|
||||
match self {
|
||||
Self::Lane(id) | Self::Message(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Refusal {
|
||||
InvalidSavedView(Id),
|
||||
TargetNotRendered(Target),
|
||||
InvalidDelta,
|
||||
InvalidGesture,
|
||||
CameraChanged,
|
||||
TargetNotHit(Target),
|
||||
Command(String),
|
||||
Geometry(Vec<GeometryIssue>),
|
||||
}
|
||||
|
||||
pub(super) struct Drag {
|
||||
original: Project,
|
||||
diagram: Id,
|
||||
target: Target,
|
||||
origin: f32,
|
||||
original_geometry: ResolvedScenarioPlan,
|
||||
}
|
||||
|
||||
pub(super) struct Preview {
|
||||
pub(super) command: Command,
|
||||
#[cfg(test)]
|
||||
pub(super) project: Project,
|
||||
pub(super) geometry: ResolvedScenarioPlan,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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)) =>
|
||||
{
|
||||
// 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| {
|
||||
matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}) =>
|
||||
{
|
||||
// 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)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
.ok_or(Refusal::TargetNotRendered(target))?;
|
||||
if !origin.is_finite() {
|
||||
return Err(Refusal::TargetNotRendered(target));
|
||||
}
|
||||
Ok(Drag {
|
||||
original: project.clone(),
|
||||
diagram,
|
||||
target,
|
||||
origin,
|
||||
original_geometry: geometry,
|
||||
})
|
||||
}
|
||||
|
||||
impl Drag {
|
||||
/// None means there is no command to commit. In particular +0/-0, return to
|
||||
/// start, and sub-precision motion never materialize a missing override or
|
||||
/// normalize an existing explicit value. Document/redo are never touched.
|
||||
pub(super) fn prepare(&self, original_delta: f32) -> Result<Option<Preview>, Refusal> {
|
||||
if !original_delta.is_finite() {
|
||||
return Err(Refusal::InvalidDelta);
|
||||
}
|
||||
if original_delta == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
let position = (f64::from(self.origin) + f64::from(original_delta)) as f32;
|
||||
if position == self.origin {
|
||||
return Ok(None);
|
||||
}
|
||||
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()))?;
|
||||
let geometry = resolve(
|
||||
&project,
|
||||
Representation::Saved {
|
||||
diagram: self.diagram,
|
||||
},
|
||||
self.target.id(),
|
||||
)
|
||||
.map_err(Refusal::Geometry)?;
|
||||
Ok(Some(Preview {
|
||||
command,
|
||||
#[cfg(test)]
|
||||
project,
|
||||
geometry,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! One press owns one immutable accepted Project and one frozen display camera.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
Scene, SceneHitRole,
|
||||
scenario::{CanvasTool, Context, State},
|
||||
scenario_layout::Diagnostic,
|
||||
};
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
#[cfg(test)]
|
||||
mod timing_tests;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Stamp {
|
||||
pub(crate) context: Context,
|
||||
pub(crate) camera_epoch: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Event {
|
||||
Begin {
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
target: Target,
|
||||
press_screen: Point,
|
||||
canvas_bounds: Rectangle,
|
||||
scale: f32,
|
||||
pan: Vector,
|
||||
},
|
||||
Move {
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
screen: Point,
|
||||
},
|
||||
Finish {
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
screen: Point,
|
||||
},
|
||||
Cancel {
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
},
|
||||
}
|
||||
impl Event {
|
||||
pub(crate) fn stamp(self) -> Stamp {
|
||||
match self {
|
||||
Self::Begin { stamp, .. }
|
||||
| Self::Move { stamp, .. }
|
||||
| Self::Finish { stamp, .. }
|
||||
| Self::Cancel { stamp, .. } => stamp,
|
||||
}
|
||||
}
|
||||
pub(crate) fn token(self) -> Id {
|
||||
match self {
|
||||
Self::Begin { token, .. }
|
||||
| Self::Move { token, .. }
|
||||
| Self::Finish { token, .. }
|
||||
| Self::Cancel { token, .. } => token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Session {
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
press_screen: Point,
|
||||
scale: f32,
|
||||
pan: Vector,
|
||||
drag: Drag,
|
||||
preview: Option<Preview>,
|
||||
refusal: Option<Refusal>,
|
||||
ever_crossed: bool,
|
||||
}
|
||||
|
||||
pub(in crate::behavior) struct Paint<'a> {
|
||||
pub(in crate::behavior) scene: &'a Scene,
|
||||
#[cfg(test)]
|
||||
pub(in crate::behavior) bounds: Rectangle,
|
||||
pub(in crate::behavior) refusal: Option<&'a Refusal>,
|
||||
pub(in crate::behavior) original_diagnostics: &'a [Diagnostic],
|
||||
pub(in crate::behavior) original_latent: &'a [Id],
|
||||
pub(in crate::behavior) diagnostics: &'a [Diagnostic],
|
||||
}
|
||||
|
||||
pub(crate) enum Finish {
|
||||
Click(Id),
|
||||
Noop,
|
||||
Commit {
|
||||
command: Box<Command>,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
pan: Vector,
|
||||
},
|
||||
}
|
||||
|
||||
impl Session {
|
||||
fn configure(&self, scene: &mut Scene) {
|
||||
scene.selected = self.drag.target.id();
|
||||
scene.scale = self.scale;
|
||||
scene.camera_pan = self.pan;
|
||||
scene.interaction = Some((self.stamp.context, CanvasTool::Select, true));
|
||||
scene.spacing_stamp = Some(self.stamp);
|
||||
scene.coordinate_input = true;
|
||||
scene.pending_placement = None;
|
||||
}
|
||||
fn update(&mut self, screen: Point) {
|
||||
self.preview = None;
|
||||
self.refusal = None;
|
||||
if !screen.x.is_finite() || !screen.y.is_finite() {
|
||||
self.refusal = Some(Refusal::InvalidDelta);
|
||||
return;
|
||||
}
|
||||
let distance = match self.drag.target {
|
||||
Target::Lane(_) => screen.x - self.press_screen.x,
|
||||
Target::Message(_) => screen.y - self.press_screen.y,
|
||||
};
|
||||
self.ever_crossed |= distance.abs() >= 3.;
|
||||
if !self.ever_crossed {
|
||||
return;
|
||||
}
|
||||
match self.drag.prepare(distance / self.scale) {
|
||||
Ok(Some(mut preview)) => {
|
||||
self.configure(&mut preview.geometry.scene);
|
||||
self.preview = Some(preview);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => self.refusal = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn spacing_token(&self) -> Option<Id> {
|
||||
self.spacing.as_ref().map(|s| s.token)
|
||||
}
|
||||
pub(crate) fn spacing_stamp(&self) -> Option<Stamp> {
|
||||
self.spacing.as_ref().map(|s| s.stamp)
|
||||
}
|
||||
pub(crate) fn cancel_spacing(&mut self) -> bool {
|
||||
self.spacing.take().is_some()
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn spacing_paint(
|
||||
&self,
|
||||
context: Context,
|
||||
camera_epoch: u64,
|
||||
) -> Option<Paint<'_>> {
|
||||
let session = self.spacing.as_ref()?;
|
||||
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,
|
||||
#[cfg(test)]
|
||||
bounds: shown.bounds,
|
||||
refusal: session.refusal.as_ref(),
|
||||
original_diagnostics: &original.plan.diagnostics,
|
||||
original_latent: &original.latent_messages,
|
||||
diagnostics: &shown.plan.diagnostics,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn begin_spacing(&mut self, project: &Project, event: Event) -> Result<(), Refusal> {
|
||||
let Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen,
|
||||
canvas_bounds,
|
||||
scale,
|
||||
pan,
|
||||
} = event
|
||||
else {
|
||||
return Err(Refusal::InvalidGesture);
|
||||
};
|
||||
let context = stamp.context;
|
||||
if self.spacing.is_some()
|
||||
|| token.is_nil()
|
||||
|| self.context(
|
||||
context.document,
|
||||
context.scenario,
|
||||
context.revision,
|
||||
context.epoch,
|
||||
) != Some(context)
|
||||
|| self.canvas_tool(context.scenario) != CanvasTool::Select
|
||||
|| ![
|
||||
press_screen.x,
|
||||
press_screen.y,
|
||||
canvas_bounds.x,
|
||||
canvas_bounds.y,
|
||||
canvas_bounds.width,
|
||||
canvas_bounds.height,
|
||||
scale,
|
||||
pan.x,
|
||||
pan.y,
|
||||
]
|
||||
.into_iter()
|
||||
.all(f32::is_finite)
|
||||
|| scale <= 0.
|
||||
|| canvas_bounds.width <= 0.
|
||||
|| canvas_bounds.height <= 0.
|
||||
|| !canvas_bounds.contains(press_screen)
|
||||
{
|
||||
return Err(Refusal::InvalidGesture);
|
||||
}
|
||||
let Representation::Saved { diagram } = context.representation else {
|
||||
return Err(Refusal::InvalidGesture);
|
||||
};
|
||||
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
|
||||
|| camera.pan != pan
|
||||
{
|
||||
return Err(Refusal::CameraChanged);
|
||||
}
|
||||
let world = Point::new(
|
||||
(press_screen.x - canvas_bounds.x - pan.x) / scale,
|
||||
(press_screen.y - canvas_bounds.y - pan.y) / scale,
|
||||
);
|
||||
// Hit tolerance is measured in displayed pixels, just like the widget.
|
||||
drag.original_geometry.scene.scale = scale;
|
||||
drag.original_geometry.scene.camera_pan = pan;
|
||||
let hit = drag.original_geometry.scene.hit_item(world);
|
||||
if !hit.is_some_and(|hit| {
|
||||
hit.id == target.id()
|
||||
&& match target {
|
||||
Target::Lane(_) => hit.role == SceneHitRole::LifelineHeader,
|
||||
Target::Message(_) => matches!(
|
||||
hit.role,
|
||||
SceneHitRole::MessageBody | SceneHitRole::MessageLabel
|
||||
),
|
||||
}
|
||||
}) {
|
||||
return Err(Refusal::TargetNotHit(target));
|
||||
}
|
||||
// Configure baseline without changing the persistent camera or selection.
|
||||
let scene = &mut drag.original_geometry.scene;
|
||||
scene.selected = target.id();
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = pan;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
scene.spacing_stamp = Some(stamp);
|
||||
scene.coordinate_input = true;
|
||||
scene.pending_placement = None;
|
||||
self.spacing = Some(Session {
|
||||
stamp,
|
||||
token,
|
||||
press_screen,
|
||||
scale,
|
||||
pan,
|
||||
drag,
|
||||
preview: None,
|
||||
refusal: None,
|
||||
ever_crossed: false,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn move_spacing(&mut self, screen: Point) {
|
||||
if let Some(session) = &mut self.spacing {
|
||||
session.update(screen);
|
||||
}
|
||||
}
|
||||
pub(crate) fn finish_spacing(&mut self, screen: Point) -> Result<Finish, Refusal> {
|
||||
let Some(mut session) = self.spacing.take() else {
|
||||
return Err(Refusal::InvalidGesture);
|
||||
};
|
||||
session.update(screen);
|
||||
if let Some(error) = session.refusal {
|
||||
return Err(error);
|
||||
}
|
||||
if !session.ever_crossed {
|
||||
return Ok(Finish::Click(session.drag.target.id()));
|
||||
}
|
||||
Ok(match session.preview {
|
||||
None => Finish::Noop,
|
||||
Some(preview) => Finish::Commit {
|
||||
command: Box::new(preview.command),
|
||||
selected: session.drag.target.id(),
|
||||
scale: session.scale,
|
||||
pan: session.pan,
|
||||
},
|
||||
})
|
||||
}
|
||||
pub(crate) fn accept_spacing_camera(&mut self, scale: f32, pan: Vector) {
|
||||
self.presentation.zoom = Some(scale);
|
||||
self.presentation.pan = pan;
|
||||
self.presentation.fit = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use iced::Size;
|
||||
use oa_core::{Diagram, Layer, ProjectionKind, language, source_project::ProjectLimits};
|
||||
|
||||
pub(crate) fn fixture() -> (Document, Id, Id, Id, Id) {
|
||||
let mut p = language::parse(r#"arcadia 7;
|
||||
project spacing "Spacing" {
|
||||
oa_activity work "Work" {}
|
||||
oa_activity response "Response" {}
|
||||
oa_scenario incident "Incident" { viewpoint OperationalActivity; steps [@first, @second]; }
|
||||
oa_lifeline reporter "Reporter" { parent @incident; represented @work; }
|
||||
oa_lifeline operator "Operator" { parent @incident; represented @response; }
|
||||
oa_occurrence first "First" { parent @incident; body { Exchange { exchange none; sender @reporter; receiver @operator; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence second "Second" { parent @incident; body { Exchange { exchange none; sender @operator; receiver @reporter; message_kind Asynchronous; }; }; }
|
||||
}"#).unwrap();
|
||||
let id = |name: &str| {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == name)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let (owner, lane, message) = (id("incident"), id("reporter"), id("first"));
|
||||
let diagram = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "saved".into(),
|
||||
name: "Saved".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
(
|
||||
Document::from_project(p, ProjectLimits::default()).unwrap(),
|
||||
owner,
|
||||
diagram,
|
||||
lane,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn expected(p: &Project, diagram: Id, target: Target, delta: f32) -> Project {
|
||||
capture(p, diagram, target)
|
||||
.unwrap()
|
||||
.prepare(delta)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.project
|
||||
}
|
||||
|
||||
pub(crate) fn packet(
|
||||
p: &Project,
|
||||
state: &State,
|
||||
context: Context,
|
||||
camera_epoch: u64,
|
||||
target: Target,
|
||||
) -> Event {
|
||||
let geometry = resolve(p, context.representation, target.id()).unwrap();
|
||||
let world = match target {
|
||||
Target::Lane(id) => geometry
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == id && item.role == SceneHitRole::LifelineHeader)
|
||||
.unwrap()
|
||||
.rect
|
||||
.center(),
|
||||
Target::Message(id) => {
|
||||
let line = geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id)
|
||||
.unwrap();
|
||||
Point::new((line.points[0].x + line.points[1].x) / 2., line.points[0].y)
|
||||
}
|
||||
};
|
||||
assert!(
|
||||
geometry
|
||||
.scene
|
||||
.hit_item(world)
|
||||
.is_some_and(|hit| hit.id == target.id())
|
||||
);
|
||||
let camera = state.presentation.camera(geometry.bounds);
|
||||
let canvas_bounds = Rectangle::new(Point::new(17., 23.), state.presentation.viewport.unwrap());
|
||||
Event::Begin {
|
||||
stamp: Stamp {
|
||||
context,
|
||||
camera_epoch,
|
||||
},
|
||||
token: Id::new_v4(),
|
||||
target,
|
||||
press_screen: Point::new(
|
||||
canvas_bounds.x + world.x * camera.scale + camera.pan.x,
|
||||
canvas_bounds.y + world.y * camera.scale + camera.pan.y,
|
||||
),
|
||||
canvas_bounds,
|
||||
scale: camera.scale,
|
||||
pan: camera.pan,
|
||||
}
|
||||
}
|
||||
fn setup(target_message: bool) -> (Document, State, Event) {
|
||||
let (doc, owner, diagram, lane, message) = fixture();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(Id::new_v4(), owner, Representation::Saved { diagram });
|
||||
state.presentation.viewport = Some(Size::new(900., 600.));
|
||||
let context = state
|
||||
.context(
|
||||
state.presentation.identity.unwrap().0,
|
||||
owner,
|
||||
doc.revision(),
|
||||
4,
|
||||
)
|
||||
.unwrap();
|
||||
let target = if target_message {
|
||||
Target::Message(message)
|
||||
} else {
|
||||
Target::Lane(lane)
|
||||
};
|
||||
let event = packet(doc.project(), &state, context, 9, target);
|
||||
(doc, state, event)
|
||||
}
|
||||
fn press(event: Event) -> Point {
|
||||
let Event::Begin { press_screen, .. } = event else {
|
||||
unreachable!()
|
||||
};
|
||||
press_screen
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_preview_moves_only_active_axis_from_original_and_never_changes_document() {
|
||||
for message in [false, true] {
|
||||
let (doc, mut state, begin) = setup(message);
|
||||
let original = doc.snapshot();
|
||||
let camera = state.camera_boundary();
|
||||
let start = press(begin);
|
||||
state.begin_spacing(doc.project(), begin).unwrap();
|
||||
let first = if message {
|
||||
Vector::new(500., 30.)
|
||||
} else {
|
||||
Vector::new(30., 500.)
|
||||
};
|
||||
state.move_spacing(start + first);
|
||||
let session = state.spacing.as_ref().unwrap();
|
||||
let candidate = session.preview.as_ref().unwrap();
|
||||
let delta = 30. / session.scale;
|
||||
let actual_anchor = match session.drag.target {
|
||||
Target::Lane(id) => candidate.geometry.scene.render_lanes[&id],
|
||||
Target::Message(id) => {
|
||||
candidate
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
.y
|
||||
}
|
||||
};
|
||||
let wanted = (f64::from(session.drag.origin) + f64::from(delta)) as f32;
|
||||
assert_eq!(actual_anchor.to_bits(), wanted.to_bits());
|
||||
let expected = session.drag.prepare(delta).unwrap().unwrap();
|
||||
assert_eq!(candidate.project, expected.project);
|
||||
assert_eq!(candidate.geometry.scene.rows, expected.geometry.scene.rows);
|
||||
assert_eq!(
|
||||
candidate.geometry.scene.render_lanes,
|
||||
expected.geometry.scene.render_lanes
|
||||
);
|
||||
state.move_spacing(start + first * 2.);
|
||||
let session = state.spacing.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
session.preview.as_ref().unwrap().project,
|
||||
session.drag.prepare(delta * 2.).unwrap().unwrap().project
|
||||
);
|
||||
assert_eq!(doc.project(), &original.decoded.project);
|
||||
assert_eq!(doc.compiled().bundle, original.bundle);
|
||||
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
|
||||
assert!(camera == state.camera_boundary());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_threshold_is_active_axis_and_return_to_zero_is_not_a_click() {
|
||||
let (doc, mut state, begin) = setup(false);
|
||||
let start = press(begin);
|
||||
state.begin_spacing(doc.project(), begin).unwrap();
|
||||
state.move_spacing(start + Vector::new(2., 100.));
|
||||
assert!(matches!(
|
||||
state.finish_spacing(start).unwrap(),
|
||||
Finish::Click(_)
|
||||
));
|
||||
state.begin_spacing(doc.project(), begin).unwrap();
|
||||
state.move_spacing(start + Vector::new(4., 0.));
|
||||
assert!(matches!(state.finish_spacing(start).unwrap(), Finish::Noop));
|
||||
assert!(!doc.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_move_discards_prior_candidate_and_finish_rechecks_final_pointer() {
|
||||
let (doc, mut state, begin) = setup(false);
|
||||
let start = press(begin);
|
||||
state.begin_spacing(doc.project(), begin).unwrap();
|
||||
state.move_spacing(start + Vector::new(20., 0.));
|
||||
state.move_spacing(Point::new(f32::NAN, start.y));
|
||||
let paint = state.spacing_paint(begin.stamp().context, 9).unwrap();
|
||||
assert!(matches!(paint.refusal, Some(Refusal::InvalidDelta)));
|
||||
assert_eq!(
|
||||
paint.scene.render_lanes,
|
||||
state
|
||||
.spacing
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.drag
|
||||
.original_geometry
|
||||
.scene
|
||||
.render_lanes
|
||||
);
|
||||
assert!(matches!(
|
||||
state.finish_spacing(Point::new(f32::NAN, start.y)),
|
||||
Err(Refusal::InvalidDelta)
|
||||
));
|
||||
assert!(state.spacing_token().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_camera_hit_role_and_retired_visit_cannot_capture() {
|
||||
let (doc, mut state, begin) = setup(false);
|
||||
let Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen,
|
||||
canvas_bounds,
|
||||
scale,
|
||||
pan,
|
||||
} = begin
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(matches!(
|
||||
state.begin_spacing(
|
||||
doc.project(),
|
||||
Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen,
|
||||
canvas_bounds,
|
||||
scale: scale * 2.,
|
||||
pan
|
||||
}
|
||||
),
|
||||
Err(Refusal::CameraChanged)
|
||||
));
|
||||
assert!(matches!(
|
||||
state.begin_spacing(
|
||||
doc.project(),
|
||||
Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen: canvas_bounds.center(),
|
||||
canvas_bounds,
|
||||
scale,
|
||||
pan
|
||||
}
|
||||
),
|
||||
Err(Refusal::TargetNotHit(_))
|
||||
));
|
||||
state.leave();
|
||||
assert!(matches!(
|
||||
state.begin_spacing(doc.project(), begin),
|
||||
Err(Refusal::InvalidGesture)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoomed_out_message_tolerance_matches_actual_canvas_hit() {
|
||||
let (doc, mut state, _) = setup(true);
|
||||
state.presentation.fit = false;
|
||||
state.presentation.zoom = Some(0.25);
|
||||
state.presentation.pan = Vector::ZERO;
|
||||
let context = state
|
||||
.context(
|
||||
state.presentation.identity.unwrap().0,
|
||||
state.owner().unwrap(),
|
||||
doc.revision(),
|
||||
4,
|
||||
)
|
||||
.unwrap();
|
||||
let message = doc
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == "first")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
let Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen,
|
||||
canvas_bounds,
|
||||
scale,
|
||||
pan,
|
||||
} = packet(doc.project(), &state, context, 9, Target::Message(message))
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let event = Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen: press_screen + Vector::new(0., 3.),
|
||||
canvas_bounds,
|
||||
scale,
|
||||
pan,
|
||||
};
|
||||
state.begin_spacing(doc.project(), event).unwrap();
|
||||
assert_eq!(state.spacing_token(), Some(token));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_screen_packet_preserves_subpixel_motion_in_preview_and_finish() {
|
||||
let (doc, owner, diagram, _, message) = fixture();
|
||||
let original = doc.snapshot();
|
||||
assert!(doc.project().diagrams[&diagram].layout.is_none());
|
||||
let mut state = State::default();
|
||||
let document = Id::new_v4();
|
||||
state.enter_representation(document, owner, Representation::Saved { diagram });
|
||||
state.presentation.viewport = Some(Size::new(900., 600.));
|
||||
state.presentation.fit = false;
|
||||
state.presentation.zoom = Some(0.5);
|
||||
// 406 + 2^-15 puts the press just below the 512 screen-coordinate boundary.
|
||||
state.presentation.pan = Vector::new(0., f32::from_bits(0x43cb_0001));
|
||||
let camera = state.camera_boundary();
|
||||
let context = state.context(document, owner, doc.revision(), 4).unwrap();
|
||||
let baseline = resolve(doc.project(), context.representation, message).unwrap();
|
||||
let line = baseline
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == message)
|
||||
.unwrap();
|
||||
assert_eq!(line.points[0].y, 148.);
|
||||
let begin = packet(doc.project(), &state, context, 9, Target::Message(message));
|
||||
let Event::Begin {
|
||||
press_screen,
|
||||
canvas_bounds,
|
||||
scale,
|
||||
pan,
|
||||
..
|
||||
} = begin
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(canvas_bounds.y, 23.);
|
||||
assert_eq!(scale, 0.5);
|
||||
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
|
||||
assert_eq!(press_screen.y.to_bits(), 0x43fb_8001); // 503 + 2^-15.
|
||||
let end = press_screen + Vector::new(0., 10.);
|
||||
assert_eq!(end.y, 513.);
|
||||
assert!(canvas_bounds.contains(press_screen) && canvas_bounds.contains(end));
|
||||
// The submitted f32 displacement is 10 - 2^-15, not exactly 10.
|
||||
assert_eq!((end.y - press_screen.y).to_bits(), 0x411f_ffe0);
|
||||
assert_eq!(((end.y - press_screen.y) / scale).to_bits(), 0x419f_ffe0);
|
||||
// Independent oracle: 148 + (10 - 2^-15) / .5 = 168 - 2^-14.
|
||||
let expected_y = f32::from_bits(0x4327_fffc);
|
||||
let mut expected = original.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(oa_core::DiagramLayout::Scenario(oa_core::ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: vec![oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: message,
|
||||
},
|
||||
send_y: expected_y,
|
||||
}],
|
||||
}));
|
||||
state.begin_spacing(doc.project(), begin).unwrap();
|
||||
state.move_spacing(end);
|
||||
let preview = state.spacing.as_ref().unwrap().preview.as_ref().unwrap();
|
||||
assert_eq!(preview.project, expected);
|
||||
let line = preview
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == message)
|
||||
.unwrap();
|
||||
assert_eq!(line.points[0].y.to_bits(), expected_y.to_bits());
|
||||
let Finish::Commit {
|
||||
command,
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
} = state.finish_spacing(end).unwrap()
|
||||
else {
|
||||
panic!("fractional motion must remain a commit")
|
||||
};
|
||||
assert_eq!(selected, message);
|
||||
assert_eq!(scale, 0.5);
|
||||
assert_eq!(pan.y.to_bits(), 0x43cb_0001);
|
||||
let committed = oa_core::execute_batch(doc.project(), &[*command]).unwrap();
|
||||
assert_eq!(committed, expected);
|
||||
assert!(state.spacing_token().is_none());
|
||||
assert_eq!(doc.project(), &original.decoded.project);
|
||||
assert_eq!(doc.compiled().bundle, original.bundle);
|
||||
assert!(!doc.dirty() && !doc.can_undo() && !doc.can_redo());
|
||||
assert!(camera == state.camera_boundary());
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Diagnostic only: production capture/prepare, excluding fixture construction,
|
||||
//! oracle work, App source writes, widget rebuilds, text shaping and GPU paint.
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{
|
||||
DiagramLayout, Layer, MessageKind, RelationKind, ScenarioLayout, source_project::ProjectLimits,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{collections::BTreeSet, sync::Arc, time::Instant};
|
||||
|
||||
const MESSAGE_COUNT: usize = 1000;
|
||||
const TARGET_INDEX: usize = 500;
|
||||
|
||||
fn large_fixture() -> (Document, Id, [Id; 3], Vec<Id>, Vec<Id>) {
|
||||
let (seed, owner, a, reporter, first) = super::tests::fixture();
|
||||
let mut p = seed.project().clone();
|
||||
let old_steps = p.elements[&owner].steps().unwrap().clone();
|
||||
let template = p.elements[&first].clone();
|
||||
let work = |alias: &str| {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let (source, target) = (work("work"), work("response"));
|
||||
let operator = work("operator");
|
||||
let relation = p.relate(
|
||||
RelationKind::Interaction,
|
||||
"Repeated incident report",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
let mut lanes = vec![];
|
||||
for index in 0..20 {
|
||||
let original = if index < 10 { reporter } else { operator };
|
||||
let mut lane = p.elements[&original].clone();
|
||||
lane.id = Id::new_v4();
|
||||
lane.alias = format!("timing_lane_{index:02}");
|
||||
lane.name = format!("Review participant {index:02}");
|
||||
lanes.push(lane.id);
|
||||
p.elements.insert(lane.id, lane);
|
||||
}
|
||||
p.elements.remove(&reporter);
|
||||
p.elements.remove(&operator);
|
||||
for id in old_steps {
|
||||
p.elements.remove(&id);
|
||||
}
|
||||
let mut messages = vec![];
|
||||
for index in 0..MESSAGE_COUNT {
|
||||
let mut occurrence = template.clone();
|
||||
occurrence.id = Id::new_v4();
|
||||
occurrence.alias = format!("timing_message_{index:04}");
|
||||
occurrence.name = format!("Validate incident report {index:04}");
|
||||
*occurrence.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(relation),
|
||||
sender: Some(lanes[index % 10]),
|
||||
receiver: Some(lanes[10 + index % 10]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
messages.push(occurrence.id);
|
||||
p.elements.insert(occurrence.id, occurrence);
|
||||
}
|
||||
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = messages.clone();
|
||||
p.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &lifeline)| ScenarioLanePosition {
|
||||
lifeline,
|
||||
x: 160. + i as f32 * 240.,
|
||||
})
|
||||
.collect(),
|
||||
messages: vec![],
|
||||
}));
|
||||
let mut views = [a, Id::nil(), Id::nil()];
|
||||
for (index, x, offset) in [(1, -500., 12.), (2, 6500., -12.)] {
|
||||
let mut view = p.diagrams[&a].clone();
|
||||
view.id = Id::new_v4();
|
||||
view.alias = format!("timing_saved_{index}");
|
||||
view.name = format!("Review arrangement {index}");
|
||||
view.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: messages[TARGET_INDEX],
|
||||
},
|
||||
send_y: 148. + TARGET_INDEX as f32 * 92. + offset,
|
||||
}],
|
||||
}));
|
||||
views[index] = view.id;
|
||||
p.diagrams.insert(view.id, view);
|
||||
}
|
||||
let document = Document::from_project(p, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
document
|
||||
.project()
|
||||
.children(owner)
|
||||
.filter(|e| e.kind() == Kind::Lifeline)
|
||||
.count(),
|
||||
20
|
||||
);
|
||||
assert_eq!(document.project().elements[&owner].layer(), Layer::Oa);
|
||||
assert_eq!(document.project().diagrams.len(), 3);
|
||||
(document, owner, views, lanes, messages)
|
||||
}
|
||||
|
||||
fn assert_scene(
|
||||
scene: &Scene,
|
||||
lanes: &[Id],
|
||||
messages: &[Id],
|
||||
moved_message: Option<(Id, f32)>,
|
||||
moved_lane: Option<(Id, f32)>,
|
||||
) {
|
||||
assert_eq!(scene.render_lanes.len(), 20);
|
||||
assert_eq!(
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|b| b.role == SceneHitRole::LifelineHeader)
|
||||
.count(),
|
||||
20
|
||||
);
|
||||
assert_eq!(scene.lines.len(), MESSAGE_COUNT);
|
||||
assert_eq!(
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.id)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
messages.iter().copied().collect::<BTreeSet<_>>()
|
||||
);
|
||||
assert!(scene.activations.is_empty() && scene.open_calls.is_empty());
|
||||
let x = |index: usize| {
|
||||
160. + index as f32 * 240.
|
||||
+ moved_lane
|
||||
.filter(|(id, _)| *id == lanes[index])
|
||||
.map_or(0., |(_, delta)| delta)
|
||||
};
|
||||
for (index, &lane) in lanes.iter().enumerate() {
|
||||
assert_eq!(scene.render_lanes[&lane], x(index));
|
||||
}
|
||||
for (index, (line, &id)) in scene.lines.iter().zip(messages).enumerate() {
|
||||
let y = 148.
|
||||
+ index as f32 * 92.
|
||||
+ moved_message
|
||||
.filter(|(target, _)| *target == id)
|
||||
.map_or(0., |(_, delta)| delta);
|
||||
assert_eq!(line.id, id);
|
||||
assert_eq!(
|
||||
line.points,
|
||||
vec![
|
||||
Point::new(x(index % 10), y),
|
||||
Point::new(x(10 + index % 10), y)
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(samples: &[Value], phase: &str) -> Value {
|
||||
let mut times: Vec<u64> = samples
|
||||
.iter()
|
||||
.filter(|sample| sample["phase"] == phase)
|
||||
.map(|sample| sample["elapsed_ns"].as_u64().unwrap())
|
||||
.collect();
|
||||
times.sort_unstable();
|
||||
let n = times.len();
|
||||
let median = if n.is_multiple_of(2) {
|
||||
((u128::from(times[n / 2 - 1]) + u128::from(times[n / 2])) / 2) as u64
|
||||
} else {
|
||||
times[n / 2]
|
||||
};
|
||||
json!({"samples": n, "min_ns": times[0], "median_ns": median, "max_ns": times[n-1]})
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Diagnostic timing only; run alone with --exact --ignored --nocapture after correctness gates; no latency acceptance threshold"]
|
||||
fn saved_spacing_capture_prepare_20_lanes_1000_messages_3_views() {
|
||||
let (document, owner, views, lanes, messages) = large_fixture();
|
||||
let original = document.snapshot();
|
||||
let target = messages[TARGET_INDEX];
|
||||
let mut samples = vec![];
|
||||
let mut retained = None;
|
||||
for iteration in 0..4 {
|
||||
let start = Instant::now();
|
||||
let result = capture(document.project(), views[0], Target::Message(target));
|
||||
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
|
||||
let drag = result.expect("complete asynchronous fixture must resolve");
|
||||
samples.push(json!({"phase":"capture_message", "iteration":iteration,
|
||||
"first_in_probe":iteration==0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
|
||||
assert_scene(&drag.original_geometry.scene, &lanes, &messages, None, None);
|
||||
if iteration == 0 {
|
||||
retained = Some(drag);
|
||||
}
|
||||
}
|
||||
let drag = retained.unwrap();
|
||||
// Every sample is relative to the original press, including duplicate,
|
||||
// reverse and return-to-start samples. Oracle work is outside each timer.
|
||||
let deltas = [
|
||||
4., 8., 12., 16., 20., 24., 28., 32., 32., 28., 24., 20., 16., 12., 8., 4., -4., -8., -4.,
|
||||
0.,
|
||||
];
|
||||
for (iteration, delta) in deltas.into_iter().enumerate() {
|
||||
let start = Instant::now();
|
||||
let result = drag.prepare(delta);
|
||||
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
|
||||
let candidate = result.expect("these isolated asynchronous offsets are feasible");
|
||||
samples.push(json!({"phase":"prepare_message", "iteration":iteration,
|
||||
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":if candidate.is_some(){"preview"}else{"noop"}}));
|
||||
if delta == 0. {
|
||||
assert!(candidate.is_none());
|
||||
} else {
|
||||
let candidate = candidate.unwrap();
|
||||
let mut expected = original.decoded.project.clone();
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
layout.messages.push(ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: target,
|
||||
},
|
||||
send_y: 148. + TARGET_INDEX as f32 * 92. + delta,
|
||||
});
|
||||
assert_eq!(candidate.project, expected);
|
||||
assert_scene(
|
||||
&candidate.geometry.scene,
|
||||
&lanes,
|
||||
&messages,
|
||||
Some((target, delta)),
|
||||
None,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
document.project().elements[&owner].steps().unwrap(),
|
||||
&messages
|
||||
);
|
||||
assert_eq!(document.project(), &original.decoded.project);
|
||||
assert!(Arc::ptr_eq(&original, &document.snapshot()));
|
||||
}
|
||||
let lane = lanes[10];
|
||||
let start = Instant::now();
|
||||
let result = capture(document.project(), views[0], Target::Lane(lane));
|
||||
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
|
||||
let drag = result.unwrap();
|
||||
samples.push(json!({"phase":"capture_lane", "iteration":0, "elapsed_ns":elapsed_ns, "outcome":"captured"}));
|
||||
for (iteration, delta) in [20., 40., -20.].into_iter().enumerate() {
|
||||
let start = Instant::now();
|
||||
let result = drag.prepare(delta);
|
||||
let elapsed_ns = u64::try_from(start.elapsed().as_nanos()).unwrap();
|
||||
let candidate = result.unwrap().unwrap();
|
||||
samples.push(json!({"phase":"prepare_lane", "iteration":iteration,
|
||||
"delta_world":delta, "elapsed_ns":elapsed_ns, "outcome":"preview"}));
|
||||
let mut expected = original.decoded.project.clone();
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut expected.diagrams.get_mut(&views[0]).unwrap().layout
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
layout.lanes[10].x = 2560. + delta;
|
||||
assert_eq!(candidate.project, expected);
|
||||
assert_scene(
|
||||
&candidate.geometry.scene,
|
||||
&lanes,
|
||||
&messages,
|
||||
None,
|
||||
Some((lane, delta)),
|
||||
);
|
||||
}
|
||||
assert_eq!(document.project(), &original.decoded.project);
|
||||
assert_eq!(document.bundle(), &original.bundle);
|
||||
for view in views {
|
||||
assert_eq!(
|
||||
document.project().diagrams[&view],
|
||||
original.decoded.project.diagrams[&view]
|
||||
);
|
||||
}
|
||||
assert_eq!(document.revision(), 0);
|
||||
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
|
||||
let report = json!({
|
||||
"schema":"scenario_spacing_timing_v1",
|
||||
"fixture":{"lanes":20,"messages":MESSAGE_COUNT,"saved_views":3,"target_index":TARGET_INDEX,"distinct_message_ids":messages.iter().copied().collect::<BTreeSet<_>>().len()},
|
||||
"profile_context":{"debug_assertions":cfg!(debug_assertions),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,
|
||||
"source_pin_from_runner":std::env::var("SCENARIO_SPACING_SOURCE_PIN").ok(),"source_binary_toolchain_binding":"external gate receipt required"},
|
||||
"summaries":{"capture_message":summary(&samples,"capture_message"),"prepare_message":summary(&samples,"prepare_message"),"capture_lane":summary(&samples,"capture_lane"),"prepare_lane":summary(&samples,"prepare_lane")},
|
||||
"samples":samples,
|
||||
"invariant_status":"passed",
|
||||
"limits":"First capture is first in this probe, not cold OS caches. Timers exclude construction/oracles, prior-preview disposal, source write/history, widget rebuild, label shaping, GPU paint and queued input; no responsiveness acceptance or latency threshold. All messages asynchronous; no nested frame/lifecycle stress."
|
||||
});
|
||||
println!("SPACING_TIMING_JSON={report}");
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::Diagnostic;
|
||||
use crate::document::Document;
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, ProjectionKind, ScenarioLayout, language, source_project::ProjectLimits,
|
||||
};
|
||||
|
||||
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 mut add = |alias: &str, layout: Option<ScenarioLayout>| {
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: oa_core::Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: layout.map(DiagramLayout::Scenario),
|
||||
},
|
||||
);
|
||||
diagram
|
||||
};
|
||||
let a = add("saved_a", layout);
|
||||
let b = add("saved_b", Some(ScenarioLayout::default()));
|
||||
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
|
||||
(document, a, b)
|
||||
}
|
||||
|
||||
fn resolved(project: &Project, diagram: Id, selected: Id) -> ResolvedScenarioPlan {
|
||||
resolve(project, Representation::Saved { diagram }, selected).unwrap()
|
||||
}
|
||||
|
||||
fn same_geometry(a: &ResolvedScenarioPlan, b: &ResolvedScenarioPlan) {
|
||||
assert_eq!(a.bounds, b.bounds);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.plan.frames, b.plan.frames);
|
||||
assert_eq!(a.plan.scopes, b.plan.scopes);
|
||||
let (a, b) = (&a.scene, &b.scene);
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.size, b.size);
|
||||
assert_eq!(a.layout_origin, b.layout_origin);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.render_lanes, b.render_lanes);
|
||||
assert_eq!(a.activations, b.activations);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
let slots = |scene: &crate::behavior::Scene| {
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.map(|item| {
|
||||
(
|
||||
item.slot.parent,
|
||||
item.slot.before,
|
||||
item.y,
|
||||
item.label.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(slots(a), slots(b));
|
||||
let lines = |scene: &crate::behavior::Scene| {
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
(
|
||||
line.id,
|
||||
line.points.clone(),
|
||||
line.label_at,
|
||||
line.label.clone(),
|
||||
line.dashed,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let boxes = |scene: &crate::behavior::Scene| {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|item| {
|
||||
(
|
||||
item.id,
|
||||
item.rect,
|
||||
item.title.clone(),
|
||||
item.detail.clone(),
|
||||
item.marker,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(lines(a), lines(b));
|
||||
assert_eq!(boxes(a), boxes(b));
|
||||
}
|
||||
|
||||
fn expected_message(project: &Project, diagram: Id, occurrence: Id, y: f32) -> Project {
|
||||
// Independent expected mutation: tests begin with no message overrides.
|
||||
let mut expected = project.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y: y,
|
||||
}],
|
||||
}));
|
||||
expected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_repeated_message_moves_in_one_view_with_exact_document_undo_redo_and_reopen() {
|
||||
let (mut document, a, b) = fixture(None);
|
||||
let original = document.snapshot();
|
||||
let before = document.project().clone();
|
||||
let first = id(&before, "first");
|
||||
let second = id(&before, "second");
|
||||
let baseline = resolved(&before, a, second);
|
||||
assert_eq!(baseline.scene.rows[&first].1, 148.);
|
||||
assert_eq!(baseline.scene.rows[&second].1, 240.);
|
||||
let untouched = resolved(&before, b, second);
|
||||
let drag = capture(&before, a, Target::Message(second)).unwrap();
|
||||
let preview = drag.prepare(-40.).unwrap().unwrap();
|
||||
let expected = expected_message(&before, a, second, 200.);
|
||||
assert_eq!(preview.project, expected);
|
||||
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
|
||||
assert_eq!(preview.geometry.scene.rows[&second].1, 200.);
|
||||
let line = preview
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == second)
|
||||
.unwrap();
|
||||
assert_eq!(line.points.first().unwrap().y, 200.);
|
||||
assert_eq!(line.points.last().unwrap().y, 200.);
|
||||
assert_eq!(
|
||||
preview.geometry.scene.activations,
|
||||
baseline.scene.activations
|
||||
);
|
||||
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
|
||||
same_geometry(&resolved(&preview.project, b, second), &untouched);
|
||||
assert_eq!(drag.original, before);
|
||||
assert!(std::sync::Arc::ptr_eq(&original, &document.snapshot()));
|
||||
assert!(!document.can_undo());
|
||||
|
||||
let returned_selection = document.execute(preview.command).unwrap();
|
||||
assert_eq!(
|
||||
returned_selection,
|
||||
Some(a),
|
||||
"controller must preserve the dragged semantic selection separately"
|
||||
);
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.revision(), 1);
|
||||
let accepted = document.bundle().clone();
|
||||
same_geometry(&resolved(document.project(), a, second), &preview.geometry);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &before);
|
||||
assert_eq!(document.bundle(), &original.bundle);
|
||||
assert!(!document.can_undo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &accepted);
|
||||
let reopened = Document::from_bundle(accepted, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.project(), &expected);
|
||||
same_geometry(&resolved(reopened.project(), a, second), &preview.geometry);
|
||||
assert!(!reopened.dirty() && !reopened.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_lane_move_changes_actual_header_and_attached_endpoints_only_in_selected_view() {
|
||||
let (document, a, b) = fixture(None);
|
||||
let before = document.project();
|
||||
let right = id(before, "right");
|
||||
let first = id(before, "first");
|
||||
let other = resolved(before, b, right);
|
||||
let preview = capture(before, a, Target::Lane(right))
|
||||
.unwrap()
|
||||
.prepare(100.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut expected = before.clone();
|
||||
expected.diagrams.get_mut(&a).unwrap().layout = Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: right,
|
||||
x: 500.,
|
||||
}],
|
||||
messages: vec![],
|
||||
}));
|
||||
assert_eq!(preview.project, expected);
|
||||
assert_eq!(preview.geometry.scene.lanes[&right], 500.);
|
||||
let header = preview
|
||||
.geometry
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == right)
|
||||
.unwrap();
|
||||
assert_eq!(header.rect.center_x(), 500.);
|
||||
let line = preview
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == first)
|
||||
.unwrap();
|
||||
assert_eq!(line.points.first().unwrap().x, 160.);
|
||||
assert_eq!(line.points.last().unwrap().x, 500.);
|
||||
assert!(line.points.iter().all(|point| point.y == 148.));
|
||||
same_geometry(&resolved(&preview.project, b, right), &other);
|
||||
assert_eq!(document.project(), before);
|
||||
assert!(!document.dirty() && !document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawn_unassigned_lifeline_can_move_without_assigning_a_semantic_participant() {
|
||||
let (document, a, _) = fixture(None);
|
||||
let mut project = document.project().clone();
|
||||
let owner = id(&project, "conversation");
|
||||
let lane = project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
oa_core::Layer::Oa,
|
||||
"Unassigned header",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let document = Document::from_project(project, ProjectLimits::default()).unwrap();
|
||||
let baseline = resolved(document.project(), a, lane);
|
||||
assert!(!baseline.scene.lanes.contains_key(&lane));
|
||||
let before_header = baseline
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == lane)
|
||||
.unwrap()
|
||||
.rect;
|
||||
let preview = capture(document.project(), a, Target::Lane(lane))
|
||||
.unwrap()
|
||||
.prepare(70.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let after_header = preview
|
||||
.geometry
|
||||
.scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == lane)
|
||||
.unwrap()
|
||||
.rect;
|
||||
assert_eq!(after_header.x, before_header.x + 70.);
|
||||
assert_eq!(after_header.y, before_header.y);
|
||||
assert_eq!(after_header.size(), before_header.size());
|
||||
assert_eq!(preview.project.elements, document.project().elements);
|
||||
assert_eq!(preview.project.relations, document.project().relations);
|
||||
assert!(!document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_spacing_uses_send_y_and_preserves_reply_and_actual_activation_finish() {
|
||||
let (document, a, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let call = id(before, "call");
|
||||
let reply = id(before, "reply");
|
||||
let baseline = resolved(before, a, call);
|
||||
let preview = capture(before, a, Target::Message(call))
|
||||
.unwrap()
|
||||
.prepare(-22.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview.project, expected_message(before, a, call, 310.));
|
||||
let line = preview
|
||||
.geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == call)
|
||||
.unwrap();
|
||||
assert_eq!(line.points.first().unwrap().y, 310.);
|
||||
assert_eq!(line.points.last().unwrap().y, 334.);
|
||||
assert_eq!(preview.geometry.scene.rows[&reply].1, 424.);
|
||||
assert_eq!(preview.geometry.scene.activations.len(), 1);
|
||||
let mut expected_activation = baseline.scene.activations.clone();
|
||||
expected_activation[0].rect.y = 334.;
|
||||
expected_activation[0].rect.height = 90.;
|
||||
assert_eq!(preview.geometry.scene.activations, expected_activation);
|
||||
assert_eq!(preview.geometry.scene.lifecycle, baseline.scene.lifecycle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feasible_spacing_crossing_remains_authored_with_diagnostic_and_unchanged_steps() {
|
||||
let (document, a, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let first = id(before, "first");
|
||||
let second = id(before, "second");
|
||||
let owner = id(before, "conversation");
|
||||
let preview = capture(before, a, Target::Message(second))
|
||||
.unwrap()
|
||||
.prepare(-120.)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(preview.project, expected_message(before, a, second, 120.));
|
||||
assert_eq!(preview.geometry.scene.rows[&second].1, 120.);
|
||||
assert_eq!(preview.geometry.scene.rows[&first].1, 148.);
|
||||
assert_eq!(
|
||||
preview.project.elements[&owner].steps(),
|
||||
before.elements[&owner].steps()
|
||||
);
|
||||
let shown = crate::behavior::scenario_layout::presentation::present(
|
||||
&preview.project,
|
||||
Representation::Saved { diagram: a },
|
||||
second,
|
||||
);
|
||||
assert!(shown.capabilities().coordinate_input);
|
||||
assert!(
|
||||
shown
|
||||
.diagnostics()
|
||||
.contains(&Diagnostic::SourceOrderCrossing {
|
||||
scope: owner,
|
||||
before: first,
|
||||
after: second
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_self_call_move_is_refused_and_automatic_fallback_is_not_editable_capture() {
|
||||
let (document, a, _) = fixture(None);
|
||||
let before = document.project();
|
||||
let call = id(before, "call");
|
||||
let drag = capture(before, a, Target::Message(call)).unwrap();
|
||||
let Err(Refusal::Geometry(issues)) = drag.prepare(118.) else {
|
||||
panic!("send450 / receive474 after reply424 must be refused")
|
||||
};
|
||||
assert!(issues.iter().any(|issue| matches!(issue, GeometryIssue::TypedActivationCrossingNotSupported { call: id, .. } if *id == call)));
|
||||
assert_eq!(&drag.original, before);
|
||||
let invalid_geometry = expected_message(before, a, call, 450.);
|
||||
let shown = crate::behavior::scenario_layout::presentation::present(
|
||||
&invalid_geometry,
|
||||
Representation::Saved { diagram: a },
|
||||
call,
|
||||
);
|
||||
assert!(shown.capabilities().geometry_navigation && !shown.capabilities().coordinate_input);
|
||||
assert!(matches!(
|
||||
capture(&invalid_geometry, a, Target::Message(call)),
|
||||
Err(Refusal::Geometry(_))
|
||||
));
|
||||
assert!(!document.dirty() && !document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_view_foreign_identity_and_latent_message_are_not_spacing_targets() {
|
||||
let (document, a, b) = fixture(None);
|
||||
let p = document.project();
|
||||
let unfinished = id(p, "unfinished");
|
||||
let source = id(p, "source");
|
||||
assert!(matches!(
|
||||
capture(p, Id::nil(), Target::Lane(source)),
|
||||
Err(Refusal::InvalidSavedView(_))
|
||||
));
|
||||
for target in [
|
||||
Target::Lane(source),
|
||||
Target::Lane(b),
|
||||
Target::Message(source),
|
||||
Target::Message(unfinished),
|
||||
] {
|
||||
assert!(
|
||||
matches!(capture(p, a, target), Err(Refusal::TargetNotRendered(found)) if found == target)
|
||||
);
|
||||
}
|
||||
// A stored latent coordinate is not permission to fabricate a painted arrow.
|
||||
let mut latent = expected_message(p, a, unfinished, 2000.);
|
||||
let foreign_owner = latent
|
||||
.add_element(Kind::Scenario, oa_core::Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
let foreign_lane = latent
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
oa_core::Layer::Oa,
|
||||
"Foreign lane",
|
||||
Some(foreign_owner),
|
||||
)
|
||||
.unwrap();
|
||||
*latent
|
||||
.elements
|
||||
.get_mut(&foreign_lane)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(source),
|
||||
};
|
||||
let latent_document = Document::from_project(latent, ProjectLimits::default()).unwrap();
|
||||
assert!(matches!(
|
||||
capture(latent_document.project(), a, Target::Message(unfinished)),
|
||||
Err(Refusal::TargetNotRendered(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
capture(latent_document.project(), a, Target::Lane(foreign_lane)),
|
||||
Err(Refusal::TargetNotRendered(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_motion_return_to_start_and_subprecision_preserve_sparse_intent_and_real_redo() {
|
||||
let parsed = language::parse(FIXTURE).unwrap();
|
||||
let left = id(&parsed, "left");
|
||||
let second = id(&parsed, "second");
|
||||
let layouts = [
|
||||
None,
|
||||
Some(ScenarioLayout::default()),
|
||||
Some(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: second,
|
||||
},
|
||||
send_y: 240.,
|
||||
}],
|
||||
}),
|
||||
Some(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: left,
|
||||
x: -0.,
|
||||
}],
|
||||
messages: vec![],
|
||||
}),
|
||||
];
|
||||
for (index, layout) in layouts.into_iter().enumerate() {
|
||||
let (mut document, a, _) = fixture(layout);
|
||||
let owner = id(document.project(), "conversation");
|
||||
document
|
||||
.execute(Command::Rename(owner, "A real redo entry".into()))
|
||||
.unwrap();
|
||||
let redo = document.snapshot();
|
||||
assert!(document.undo());
|
||||
let before = document.snapshot();
|
||||
let revision = document.revision();
|
||||
let target = if index == 3 {
|
||||
Target::Lane(left)
|
||||
} else {
|
||||
Target::Message(second)
|
||||
};
|
||||
let drag = capture(document.project(), a, target).unwrap();
|
||||
if index == 3 {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &drag.original.diagrams[&a].layout else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(layout.lanes[0].x.to_bits(), (-0_f32).to_bits());
|
||||
}
|
||||
assert!(drag.prepare(10.).unwrap().is_some());
|
||||
for delta in [0., -0.] {
|
||||
assert!(drag.prepare(delta).unwrap().is_none());
|
||||
}
|
||||
if index != 3 {
|
||||
assert!(drag.prepare(f32::from_bits(1)).unwrap().is_none());
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_vec(&drag.original).unwrap(),
|
||||
serde_json::to_vec(document.project()).unwrap()
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &document.snapshot()));
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert!(document.can_redo());
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &redo.decoded.project);
|
||||
assert_eq!(document.bundle(), &redo.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_out_of_range_motion_cannot_mutate_original() {
|
||||
let (document, a, _) = fixture(None);
|
||||
let drag = capture(
|
||||
document.project(),
|
||||
a,
|
||||
Target::Message(id(document.project(), "second")),
|
||||
)
|
||||
.unwrap();
|
||||
for delta in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
|
||||
assert!(matches!(drag.prepare(delta), Err(Refusal::InvalidDelta)));
|
||||
}
|
||||
assert!(matches!(drag.prepare(f32::MAX), Err(Refusal::Command(_))));
|
||||
assert_eq!(&drag.original, document.project());
|
||||
assert!(!document.dirty() && !document.can_undo());
|
||||
}
|
||||
|
||||
const FIXTURE: &str = r#"arcadia 7;
|
||||
project spacing_probe "Saved spacing 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" {}
|
||||
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; }; }; }
|
||||
}
|
||||
"#;
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Navigation is derived from the same opaque-reference scene that is painted.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
|
||||
/// The renderer collapses whitespace before shortening. Require at least one
|
||||
@@ -22,7 +23,14 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
|
||||
start < retained
|
||||
}
|
||||
|
||||
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
fn usages(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
subject: Id,
|
||||
surface: Surface,
|
||||
bounds: Rectangle,
|
||||
scene: &Scene,
|
||||
) -> Vec<Usage> {
|
||||
let mut result = Vec::new();
|
||||
let mut add = |id, bounds, role: &str| {
|
||||
// Activation bars share the occurrence identity with a labelled message;
|
||||
@@ -33,7 +41,7 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
{
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
surface,
|
||||
target: Target::ScenarioObject(id),
|
||||
role: role.into(),
|
||||
bounds,
|
||||
@@ -137,15 +145,15 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject == owner {
|
||||
if subject == owner || subject == surface.id() {
|
||||
result.insert(
|
||||
0,
|
||||
Usage {
|
||||
subject,
|
||||
surface: Surface::Scenario(owner),
|
||||
surface,
|
||||
target: Target::Whole,
|
||||
role: "scenario".into(),
|
||||
bounds: Rectangle::new(Point::ORIGIN, scene.size),
|
||||
bounds,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -153,15 +161,29 @@ fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
if !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
/// Membership comes from the complete presentation chosen for this exact view,
|
||||
/// including automatic fallback geometry while retaining its Saved identity.
|
||||
pub(crate) fn representation_usages(
|
||||
&self,
|
||||
p: &Project,
|
||||
representation: Representation,
|
||||
subject: Id,
|
||||
) -> Vec<Usage> {
|
||||
let shown = presentation::present(p, representation, subject);
|
||||
let (Some(owner), Some(bounds), Some(scene)) =
|
||||
(shown.owner(), shown.bounds(), shown.scene())
|
||||
else {
|
||||
return vec![];
|
||||
}
|
||||
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
|
||||
};
|
||||
let surface = match representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
};
|
||||
usages(p, owner, subject, surface, bounds, scene)
|
||||
}
|
||||
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
self.representation_usages(p, Representation::Derived { owner }, subject)
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_usage(
|
||||
@@ -171,12 +193,24 @@ impl State {
|
||||
owner: Id,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
self.reveal_representation_usage(
|
||||
p,
|
||||
document,
|
||||
owner,
|
||||
Representation::Derived { owner },
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_representation_usage(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
|
||||
return false;
|
||||
}
|
||||
let id = match target {
|
||||
@@ -185,7 +219,7 @@ impl State {
|
||||
_ => return false,
|
||||
};
|
||||
let Some(usage) = self
|
||||
.diagram_usages(p, owner, id)
|
||||
.representation_usages(p, representation, id)
|
||||
.into_iter()
|
||||
.find(|u| &u.target == target)
|
||||
else {
|
||||
@@ -193,11 +227,29 @@ impl State {
|
||||
};
|
||||
// Preserve an existing mounted viewport across enter's owner reset.
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.enter(document, owner);
|
||||
if !viewport.width.is_finite()
|
||||
|| !viewport.height.is_finite()
|
||||
|| viewport.width <= 0.
|
||||
|| viewport.height <= 0.
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (zoom, pan) = if matches!(target, Target::Whole) {
|
||||
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
|
||||
return false;
|
||||
};
|
||||
(None, camera.pan)
|
||||
} else {
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
(Some(zoom), pan)
|
||||
};
|
||||
self.enter_representation(document, owner, representation);
|
||||
self.presentation.viewport = Some(viewport);
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
self.presentation.zoom(Some(zoom));
|
||||
self.presentation.zoom(zoom);
|
||||
self.presentation.pan = pan;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ pub enum Shelf {
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Presentation {
|
||||
pub(super) identity: Option<(Id, Id)>,
|
||||
pub(super) representation: Option<Representation>,
|
||||
pub(super) visit: Id,
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) pan: iced::Vector,
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
const MIN_SCALE: f32 = 0.05;
|
||||
impl Presentation {
|
||||
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
@@ -34,29 +35,26 @@ impl Presentation {
|
||||
true
|
||||
}
|
||||
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
||||
let changed = self.zoom != next || self.fit != scale.is_none();
|
||||
self.zoom = next;
|
||||
self.fit = scale.is_none();
|
||||
changed
|
||||
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
|
||||
}
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// Fit uses the actual mounted viewport with a small visual gutter.
|
||||
Some(
|
||||
((viewport.width - 16.).max(1.) / scene.width.max(1.))
|
||||
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
|
||||
)
|
||||
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
|
||||
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
|
||||
}
|
||||
pub(super) fn scale(&self, scene: Size) -> f32 {
|
||||
if self.fit {
|
||||
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
|
||||
} else {
|
||||
self.zoom.unwrap_or(1.)
|
||||
}
|
||||
// Legacy tests call size-only scale; actual display always passes full bounds.
|
||||
#[cfg(test)]
|
||||
pub(super) fn scale(&self, size: Size) -> f32 {
|
||||
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArrangementChoice {
|
||||
representation: Representation,
|
||||
label: String,
|
||||
}
|
||||
impl std::fmt::Display for ArrangementChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,43 +66,136 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.owner == owner);
|
||||
let context = app.scenario_context().expect("visible scenario context");
|
||||
let Some(context) = app.scenario_context() else {
|
||||
return column![
|
||||
out,
|
||||
text("Scenario view is unavailable."),
|
||||
button("Open selected source").on_press(Message::SourceForSelection)
|
||||
]
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let tool = state.canvas_tool(owner);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let mut scene = Scene::sequence(p, owner, app.selected);
|
||||
let scale = presentation.scale(scene.size);
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = presentation.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.pending_placement = state.pending_placement(owner);
|
||||
// Resolve once per accepted view. An active spacing session owns its exact
|
||||
// prepared geometry; borrowing it also retains the canvas input state.
|
||||
let preview = state.spacing_paint(context, app.camera_epoch);
|
||||
let shown = preview
|
||||
.is_none()
|
||||
.then(|| presentation::present(p, context.representation, app.selected));
|
||||
let capabilities = shown.as_ref().map_or(
|
||||
presentation::PresentationCapabilities {
|
||||
geometry_navigation: true,
|
||||
coordinate_input: true,
|
||||
},
|
||||
ScenarioPresentation::capabilities,
|
||||
);
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let notice = if let Some(paint) = &preview {
|
||||
display_notice_parts(
|
||||
p,
|
||||
context.representation,
|
||||
&presentation::PresentationStatus::Ready,
|
||||
paint.original_diagnostics,
|
||||
paint.original_latent,
|
||||
)
|
||||
} else {
|
||||
display_notice(p, shown.as_ref().expect("normal presentation"))
|
||||
};
|
||||
let camera_state = preview
|
||||
.as_ref()
|
||||
.map(|paint| camera::Camera {
|
||||
scale: paint.scene.scale,
|
||||
pan: paint.scene.camera_pan,
|
||||
})
|
||||
.or_else(|| {
|
||||
shown
|
||||
.as_ref()
|
||||
.and_then(ScenarioPresentation::bounds)
|
||||
.map(|bounds| presentation.camera(bounds))
|
||||
});
|
||||
let scale = camera_state.map_or(1., |camera| camera.scale);
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
};
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let keyboard = clean
|
||||
&& capabilities.geometry_navigation
|
||||
&& super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
let canvas = container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select),
|
||||
let raw_canvas: Option<Element<'a, Message>> = if let Some(paint) = &preview {
|
||||
Some(
|
||||
canvas::Canvas::new(paint.scene)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into(),
|
||||
)
|
||||
.key((context.document, owner))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
} else if let Some(mut scene) = shown.and_then(ScenarioPresentation::into_scene) {
|
||||
let camera_state = camera_state.expect("displayed Scene has complete bounds");
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.spacing_stamp = (coordinate_input
|
||||
&& !app.source_rename.open()
|
||||
&& keyboard
|
||||
&& matches!(context.representation, Representation::Saved { .. }))
|
||||
.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();
|
||||
Some(canvas::Canvas::new(scene).width(Fill).height(Fill).into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let canvas: Element<'a, Message> = if let Some(raw_canvas) = raw_canvas {
|
||||
// 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(paint) = &preview {
|
||||
let feedback = spacing_feedback(p, paint.refusal, paint.diagnostics);
|
||||
layers = layers.push(
|
||||
container(
|
||||
container(text(feedback).size(12))
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.width(Fill)
|
||||
.align_bottom(Fill)
|
||||
.padding(8),
|
||||
);
|
||||
}
|
||||
container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
layers, scope, scale, keyboard, camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
|
||||
)
|
||||
.key((
|
||||
context.document,
|
||||
format!("{:?}", context.representation),
|
||||
context.visit,
|
||||
))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
} else {
|
||||
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()
|
||||
};
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
@@ -112,20 +203,72 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
));
|
||||
}
|
||||
let header = column![
|
||||
navigation,
|
||||
super::super::canvas_workspace::controls(
|
||||
let mut header = column![navigation, notice].spacing(4);
|
||||
let saved_views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|diagram| {
|
||||
presentation::checked_owner(
|
||||
p,
|
||||
Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
) == Some(owner)
|
||||
})
|
||||
.map(|diagram| ArrangementChoice {
|
||||
representation: Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
label: short(&diagram.name, 64),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !saved_views.is_empty() {
|
||||
let mut choices = vec![ArrangementChoice {
|
||||
representation: Representation::Derived { owner },
|
||||
label: "Automatic arrangement".into(),
|
||||
}];
|
||||
choices.extend(saved_views);
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|choice| choice.representation == context.representation)
|
||||
.cloned();
|
||||
header = header.push(
|
||||
container(
|
||||
pick_list(choices, selected, move |choice: ArrangementChoice| {
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
context,
|
||||
choice.representation,
|
||||
))
|
||||
})
|
||||
.placeholder("Choose arrangement")
|
||||
.text_size(12)
|
||||
.padding(5)
|
||||
.width(Fill),
|
||||
)
|
||||
.width(Fill)
|
||||
.id("scenario-arrangement-picker"),
|
||||
);
|
||||
}
|
||||
if capabilities.geometry_navigation {
|
||||
header = header.push(super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
||||
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(4);
|
||||
camera,
|
||||
));
|
||||
}
|
||||
let entries = [
|
||||
("S", "Select / reorder occurrences", CanvasTool::Select),
|
||||
(
|
||||
"S",
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
"Select / move messages and lifelines"
|
||||
} else {
|
||||
"Select / reorder occurrences"
|
||||
},
|
||||
CanvasTool::Select,
|
||||
),
|
||||
(
|
||||
"L",
|
||||
"Lifeline: click to choose represented work or participant",
|
||||
@@ -192,16 +335,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
glyph,
|
||||
label,
|
||||
choice == tool,
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
|
||||
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
let mut details = column![
|
||||
text(format!("Selected: {}", p.named(app.selected))).size(12),
|
||||
button(text("Properties").size(12))
|
||||
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
|
||||
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)),
|
||||
),
|
||||
))
|
||||
.id("scenario-properties")
|
||||
]
|
||||
.spacing(6);
|
||||
if referenced_scenario(p, owner, app.selected).is_some() {
|
||||
@@ -212,6 +360,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
))),
|
||||
));
|
||||
}
|
||||
details = details.push(text("Scenario order").size(13));
|
||||
if matches!(context.representation, Representation::Saved { .. }) {
|
||||
details = details.push(text("↑ and ↓ change the shared Scenario order. Saved message positions are retained; automatically placed items may move.").size(11));
|
||||
}
|
||||
let details = details_pane(
|
||||
context,
|
||||
state.scroll_restore(context, ScrollArea::Page),
|
||||
@@ -541,6 +693,153 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
|
||||
display_notice_parts(
|
||||
p,
|
||||
shown.requested(),
|
||||
shown.status(),
|
||||
shown.diagnostics(),
|
||||
shown.latent_messages(),
|
||||
)
|
||||
}
|
||||
|
||||
fn display_notice_parts<'a>(
|
||||
p: &Project,
|
||||
requested: Representation,
|
||||
status: &presentation::PresentationStatus,
|
||||
diagnostics: &[super::super::scenario_layout::Diagnostic],
|
||||
latent_messages: &[Id],
|
||||
) -> Element<'a, Message> {
|
||||
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
|
||||
let title = match requested {
|
||||
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|
||||
|| "Unavailable saved Scenario view".into(),
|
||||
|view| format!("Saved view · {}", view.name),
|
||||
),
|
||||
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
|
||||
};
|
||||
let mut notice = column![text(title).size(14)].spacing(4);
|
||||
match status {
|
||||
PresentationStatus::Ready => {
|
||||
for diagnostic in diagnostics.iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"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.",
|
||||
p.named(*frame),
|
||||
p.named(*following)
|
||||
),
|
||||
};
|
||||
notice = notice.push(text(explanation).size(12));
|
||||
}
|
||||
if diagnostics.len() > 3 {
|
||||
notice = notice.push(
|
||||
text(format!(
|
||||
"{} additional spacing notices are retained.",
|
||||
diagnostics.len() - 3
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
PresentationStatus::Fallback { requested_issues } => {
|
||||
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
|
||||
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. 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));
|
||||
}
|
||||
}
|
||||
if !latent_messages.is_empty() {
|
||||
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", latent_messages.len())).size(12));
|
||||
}
|
||||
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
|
||||
container(notice)
|
||||
.id("scenario-display-notice")
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn spacing_feedback(
|
||||
p: &Project,
|
||||
refusal: Option<&spacing::Refusal>,
|
||||
diagnostics: &[super::super::scenario_layout::Diagnostic],
|
||||
) -> String {
|
||||
if let Some(refusal) = refusal {
|
||||
let reason = match refusal {
|
||||
spacing::Refusal::Geometry(issues) => issues
|
||||
.first()
|
||||
.map(|issue| geometry_problem(p, issue))
|
||||
.unwrap_or_else(|| "This position cannot be displayed.".into()),
|
||||
spacing::Refusal::Command(error) => error.clone(),
|
||||
_ => "This position is unavailable in the current view.".into(),
|
||||
};
|
||||
return format!(
|
||||
"Position unavailable · {reason} Move back to a supported position, or release to cancel."
|
||||
);
|
||||
}
|
||||
let explanation = match diagnostics.first() {
|
||||
Some(super::super::scenario_layout::Diagnostic::SourceOrderCrossing {
|
||||
before,
|
||||
after,
|
||||
..
|
||||
}) => format!(
|
||||
" '{}' is below '{}'; Scenario order stays unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Some(super::super::scenario_layout::Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame,
|
||||
following,
|
||||
}) => format!(" '{}' overlaps '{}'.", p.named(*frame), p.named(*following)),
|
||||
None => " Scenario order stays unchanged.".into(),
|
||||
};
|
||||
format!("Spacing preview · Release to finish; Escape cancels.{explanation}")
|
||||
}
|
||||
|
||||
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
|
||||
use super::super::scenario_layout::GeometryIssue;
|
||||
match issue {
|
||||
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
|
||||
"The position of '{}' conflicts with the execution of '{}'.",
|
||||
p.named(*event),
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
|
||||
"The distinct endpoints of '{}' have the same horizontal position.",
|
||||
p.named(*message)
|
||||
),
|
||||
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
|
||||
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
|
||||
"The position of '{}' falls outside its supported execution interval.",
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
|
||||
"The saved spacing would move the fixed header of '{}'.",
|
||||
p.named(*frame)
|
||||
),
|
||||
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
|
||||
"The spacing of '{}' extends outside its enclosing frame.",
|
||||
p.named(*operand)
|
||||
),
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
|
||||
"The saved spacing cannot preserve the participant lifetime geometry.".into()
|
||||
}
|
||||
_ => "These saved positions are outside the geometry currently supported by the canvas."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
title: &str,
|
||||
message: Message,
|
||||
@@ -597,6 +896,12 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod spacing_tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod details_tests;
|
||||
|
||||
fn sequence_rows<'a>(
|
||||
p: &'a Project,
|
||||
context: Context,
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Mounted Scenario Details → inspector regression and retained-intent guards.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use crate::behavior::scenario_layout::resolve;
|
||||
use iced::advanced::widget;
|
||||
use iced::{Vector, keyboard};
|
||||
use std::sync::Arc;
|
||||
|
||||
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
|
||||
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(event(context)));
|
||||
}
|
||||
fn setup() -> (App, Id, Id) {
|
||||
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
|
||||
let owner = id(&app, "incident_response");
|
||||
let diagram = id(&app, "incident_readable");
|
||||
let message = id(&app, "initial_report");
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: "A".into(),
|
||||
}));
|
||||
send(&mut app, |c| CanvasEvent::Select(c, message));
|
||||
app.window_size = Size::new(1440., 1000.);
|
||||
(app, diagram, message)
|
||||
}
|
||||
fn open_details(app: &mut App) {
|
||||
if app.behavior.scenario.presentation.shelf.is_none() {
|
||||
send(app, |c| CanvasEvent::Shelf(c, Shelf::Selection));
|
||||
}
|
||||
}
|
||||
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
|
||||
let p = &app.behavior.scenario.presentation;
|
||||
(p.pan, p.zoom, p.fit)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_details_opens_real_editable_inspector_and_returns_to_same_view() {
|
||||
let (mut app, diagram, message) = setup();
|
||||
let _ = app.update(Message::InspectorTab(
|
||||
crate::navigation::InspectorTab::Connections,
|
||||
));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
open_details(&mut app);
|
||||
mounted.settle(&mut app).await;
|
||||
let before = mounted.geometry(&app);
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Connections
|
||||
);
|
||||
assert!(!before.scrolls.contains_key(&widget::Id::new("inspector")));
|
||||
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
|
||||
let button = before.bounds("scenario-properties").center() - offset;
|
||||
assert!(
|
||||
details.contains(button),
|
||||
"Properties must be actually visible"
|
||||
);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = stored_camera(&app);
|
||||
let original = app.document.snapshot();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let messages = mounted.click(&mut app, button).await;
|
||||
assert!(messages.iter().any(|event| matches!(event,
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
|
||||
if *c == context && *selected == message)));
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.behavior.scenario.presentation.shelf.is_none());
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Properties
|
||||
);
|
||||
assert_eq!(app.selected, message);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.page, crate::app::Page::Behavior);
|
||||
let current = app.scenario_context().unwrap();
|
||||
assert_eq!(current.representation, context.representation);
|
||||
assert_eq!(current.visit, context.visit);
|
||||
assert_eq!(
|
||||
stored_camera(&app),
|
||||
camera,
|
||||
"opening Properties retains camera intent"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
|
||||
let shown = mounted.geometry(&app);
|
||||
assert!(
|
||||
!shown
|
||||
.scrolls
|
||||
.contains_key(&widget::Id::new("scenario-details"))
|
||||
);
|
||||
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
|
||||
assert!(inspector.width > 250. && inspector.height > 500.);
|
||||
// Production Properties places Name before the other scalar text inputs.
|
||||
// Account for the retained inspector scroll, rather than clicking content coordinates.
|
||||
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 editable property");
|
||||
mounted.click(&mut app, input).await;
|
||||
let typed = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character("x".into()),
|
||||
modified_key: keyboard::Key::Character("x".into()),
|
||||
physical_key: keyboard::key::Physical::Unidentified(
|
||||
keyboard::key::NativeCode::Unidentified,
|
||||
),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: Some("x".into()),
|
||||
repeat: false,
|
||||
})],
|
||||
mouse::Cursor::Available(input),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
typed
|
||||
.iter()
|
||||
.any(|message| matches!(message, Message::Field(path, _) if path == "/name")),
|
||||
"real Name editor must publish its edit: {typed:?}"
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(
|
||||
Arc::ptr_eq(&original, &app.document.snapshot()),
|
||||
"typing is only a draft"
|
||||
);
|
||||
let _ = app.update(Message::RevertProperties);
|
||||
assert!(!app.draft_dirty());
|
||||
mounted.settle(&mut app).await;
|
||||
let returned = mounted.geometry(&app);
|
||||
assert!(returned.scrolls.contains_key(&widget::Id::new("inspector")));
|
||||
let viewport = returned.bounds("scenario-viewport");
|
||||
assert_eq!(app.selected, message);
|
||||
assert_eq!(app.scenario_context().unwrap().visit, context.visit);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
let geometry = resolve(app.project(), context.representation, message).unwrap();
|
||||
let line = geometry
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == message)
|
||||
.unwrap();
|
||||
let effective = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
let point = viewport.position()
|
||||
+ effective.pan
|
||||
+ Vector::new(line.points[0].x, line.points[0].y) * effective.scale;
|
||||
assert!(
|
||||
viewport.contains(point),
|
||||
"the same selected message remains in fitted view"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_properties_refuses_pending_property_source_and_behavior_drafts_without_apply() {
|
||||
for kind in 0..3 {
|
||||
let (mut app, _, message) = setup();
|
||||
open_details(&mut app);
|
||||
match kind {
|
||||
0 => {
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Retained property draft".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
1 => {
|
||||
let _ = app.update(Message::SourceEdit(
|
||||
iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
),
|
||||
));
|
||||
assert!(app.source_dirty());
|
||||
}
|
||||
_ => {
|
||||
let owner = app.scenario_context().unwrap().scenario;
|
||||
let _ = app.update(Message::Behavior(super::super::super::Event::Scenario(
|
||||
Event::Start(owner, Tool::Lifeline),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
}
|
||||
let snapshot = app.document.snapshot();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let property = app.draft.as_ref().unwrap().materialize();
|
||||
let source = app.source_editor.text();
|
||||
let behavior = app.behavior.recovery_snapshot();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let context = app.scenario_context().unwrap();
|
||||
let epochs = (app.canvas_epoch, app.camera_epoch);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
|
||||
context, message,
|
||||
)));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.scenario_context(), Some(context));
|
||||
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
|
||||
assert_eq!(app.draft.as_ref().unwrap().materialize(), property);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert_eq!(app.behavior.recovery_snapshot(), behavior);
|
||||
assert_eq!(app.selected, message);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn details_opens_current_saved_view_properties_after_following_diagram_picker() {
|
||||
let (mut app, a, _) = setup();
|
||||
let b = id(&app, "incident_wide");
|
||||
// Selecting the current view record makes the real picker follow that
|
||||
// record selection to B. Do not seed selected=B or its property draft.
|
||||
let _ = app.update(Message::Select(a));
|
||||
assert_eq!(app.selected, a);
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: b,
|
||||
label: "B".into(),
|
||||
}));
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, b);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: b }
|
||||
);
|
||||
let _ = app.update(Message::InspectorTab(
|
||||
crate::navigation::InspectorTab::Connections,
|
||||
));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
open_details(&mut app);
|
||||
mounted.settle(&mut app).await;
|
||||
let context = app.scenario_context().unwrap();
|
||||
let camera = stored_camera(&app);
|
||||
let snapshot = app.document.snapshot();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let before = mounted.geometry(&app);
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Connections
|
||||
);
|
||||
assert!(!before.scrolls.contains_key(&widget::Id::new("inspector")));
|
||||
let (details, offset) = before.scrolls[&widget::Id::new("scenario-details")];
|
||||
let point = before.bounds("scenario-properties").center() - offset;
|
||||
assert!(details.contains(point));
|
||||
let events = mounted.click(&mut app, point).await;
|
||||
assert!(events.iter().any(|event| matches!(event,
|
||||
Message::ScenarioCanvas(CanvasEvent::Properties(c, selected))
|
||||
if *c == context && *selected == b)));
|
||||
mounted.settle(&mut app).await;
|
||||
let shown = mounted.geometry(&app);
|
||||
assert!(
|
||||
!shown
|
||||
.scrolls
|
||||
.contains_key(&widget::Id::new("scenario-details"))
|
||||
);
|
||||
let (inspector, offset) = shown.scrolls[&widget::Id::new("inspector")];
|
||||
assert!(
|
||||
shown
|
||||
.text_inputs
|
||||
.iter()
|
||||
.any(|bounds| inspector.contains(bounds.center() - offset)),
|
||||
"current Saved diagram has a visible editable inspector"
|
||||
);
|
||||
assert_eq!(
|
||||
app.inspector_tab,
|
||||
crate::navigation::InspectorTab::Properties
|
||||
);
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, b);
|
||||
assert_eq!(app.active_diagram, Some(b));
|
||||
assert_eq!(app.page, crate::app::Page::Behavior);
|
||||
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(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_properties_refuses_another_saved_view_even_with_the_same_scenario_owner() {
|
||||
let (mut app, a, _) = setup();
|
||||
let b = id(&app, "incident_wide");
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: b,
|
||||
label: "B".into(),
|
||||
}));
|
||||
let _ = app.update(Message::Select(a));
|
||||
assert_eq!(app.selected, a);
|
||||
open_details(&mut app);
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.representation, Representation::Saved { diagram: b });
|
||||
assert_eq!(
|
||||
presentation::checked_owner(app.project(), Representation::Saved { diagram: a }),
|
||||
Some(context.scenario)
|
||||
);
|
||||
assert!(!properties_target(app.project(), context, a));
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let epochs = (app.canvas_epoch, app.camera_epoch);
|
||||
let snapshot = app.document.snapshot();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(context, a)));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
|
||||
assert_eq!(app.scenario_context(), Some(context));
|
||||
assert_eq!(app.selected, a);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, a);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn details_properties_rejects_retired_view_selection_and_epoch_without_changing_panels() {
|
||||
for kind in 0..3 {
|
||||
let (mut app, diagram, message) = setup();
|
||||
open_details(&mut app);
|
||||
let captured = app.scenario_context().unwrap();
|
||||
match kind {
|
||||
0 => {
|
||||
let other = id(&app, "incident_wide");
|
||||
for view in [other, diagram] {
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: view,
|
||||
label: "view".into(),
|
||||
}));
|
||||
}
|
||||
open_details(&mut app);
|
||||
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
|
||||
}
|
||||
1 => {
|
||||
let other = id(&app, "updated_report");
|
||||
send(&mut app, |c| CanvasEvent::Select(c, other));
|
||||
}
|
||||
_ => {
|
||||
send(&mut app, |c| CanvasEvent::Zoom(c, Some(0.5)));
|
||||
}
|
||||
}
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let context = app.scenario_context();
|
||||
let selected = app.selected;
|
||||
let tab = app.inspector_tab;
|
||||
let epochs = (app.canvas_epoch, app.camera_epoch);
|
||||
let snapshot = app.document.snapshot();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Properties(
|
||||
captured, message,
|
||||
)));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.scenario_context(), context);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.inspector_tab, tab);
|
||||
assert_eq!((app.canvas_epoch, app.camera_epoch), epochs);
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_scenario_properties_cancels_held_spacing_and_rejects_its_late_finish() {
|
||||
use super::super::spacing::{Event as Spacing, Target, controller::tests::packet};
|
||||
let (mut app, _, message) = setup();
|
||||
send(&mut app, |c| {
|
||||
CanvasEvent::Viewport(c, Size::new(900., 600.))
|
||||
});
|
||||
open_details(&mut app);
|
||||
let begin = packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
Target::Message(message),
|
||||
);
|
||||
let Spacing::Begin { press_screen, .. } = begin else {
|
||||
panic!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(begin)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(begin.token()));
|
||||
let snapshot = app.document.snapshot();
|
||||
let end = press_screen + Vector::new(0., 12.);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
Spacing::Move {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen: end,
|
||||
},
|
||||
)));
|
||||
let paint = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_paint(begin.stamp().context, begin.stamp().camera_epoch)
|
||||
.unwrap();
|
||||
assert!(paint.refusal.is_none());
|
||||
let line = paint
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == message)
|
||||
.unwrap();
|
||||
assert!(
|
||||
line.points[0].y > 160.,
|
||||
"a real proposed scene moved the first message"
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&snapshot, &app.document.snapshot()),
|
||||
"preview is not authored"
|
||||
);
|
||||
let camera = stored_camera(&app);
|
||||
send(&mut app, |c| CanvasEvent::Properties(c, message));
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
Spacing::Finish {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen: end,
|
||||
},
|
||||
)));
|
||||
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Real mounted widgets dispatch the gesture; expectations name authored fields
|
||||
//! and actual displayed anchors independently of controller command results.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use crate::behavior::scenario_layout::{ResolvedScenarioPlan, resolve};
|
||||
use crate::document::Document;
|
||||
use iced::Vector;
|
||||
use std::sync::Arc;
|
||||
|
||||
const FIXTURE: &str = include_str!("../../../app/diagram_usages/tests/saved_scenario.arcadia");
|
||||
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
async fn setup(fit: bool) -> (App, Mounted, Id) {
|
||||
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
|
||||
let owner = id(&app, "incident_response");
|
||||
let diagram = id(&app, "incident_readable");
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(crate::app::Choice {
|
||||
id: diagram,
|
||||
label: "A - Incident review readable".into(),
|
||||
}));
|
||||
app.window_size = Size::new(1440., 900.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
if !fit {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let task = app.update(Message::ScenarioCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(0.5),
|
||||
)));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
}
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram }
|
||||
);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
(app, mounted, diagram)
|
||||
}
|
||||
|
||||
fn resolved(app: &App) -> ResolvedScenarioPlan {
|
||||
resolve(
|
||||
app.project(),
|
||||
app.scenario_context().unwrap().representation,
|
||||
app.selected,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn stored_camera(app: &App) -> (Vector, Option<f32>, bool) {
|
||||
let p = &app.behavior.scenario.presentation;
|
||||
(p.pan, p.zoom, p.fit)
|
||||
}
|
||||
|
||||
fn screen(mounted: &mut Mounted, app: &App, world: Point) -> Point {
|
||||
let bounds = mounted.geometry(app).bounds("scenario-viewport");
|
||||
let geometry = resolved(app);
|
||||
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),
|
||||
"actual visible world {world:?}: {point:?} in {bounds:?}"
|
||||
);
|
||||
point
|
||||
}
|
||||
|
||||
fn mouse_event(event: mouse::Event) -> iced::Event {
|
||||
iced::Event::Mouse(event)
|
||||
}
|
||||
|
||||
async fn press(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[
|
||||
mouse_event(mouse::Event::CursorMoved { position: point }),
|
||||
mouse_event(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn move_to(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[mouse_event(mouse::Event::CursorMoved { position: point })],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn release(mounted: &mut Mounted, app: &mut App, point: Point) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[mouse_event(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn set_expected_message(project: &mut oa_core::Project, diagram: Id, occurrence: Id, y: f32) {
|
||||
let Some(oa_core::DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&diagram).unwrap().layout
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
layout
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|m| m.address.occurrence == occurrence)
|
||||
.unwrap()
|
||||
.send_y = y;
|
||||
}
|
||||
|
||||
fn set_expected_lane(project: &mut oa_core::Project, diagram: Id, lifeline: Id, x: f32) {
|
||||
let Some(oa_core::DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&diagram).unwrap().layout
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
layout
|
||||
.lanes
|
||||
.iter_mut()
|
||||
.find(|lane| lane.lifeline == lifeline)
|
||||
.unwrap()
|
||||
.x = x;
|
||||
}
|
||||
|
||||
fn assert_reopens_exactly(app: &App) {
|
||||
let reopened = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reopened.project(), app.project());
|
||||
assert_eq!(reopened.bundle(), app.document.bundle());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_message_same_batch_pointer_events_commit_once_and_one_undo_redo_restores_exact_source()
|
||||
{
|
||||
let (mut app, mut mounted, diagram) = setup(false).await;
|
||||
let target = id(&app, "updated_report");
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let projected = screen(&mut mounted, &app, Point::new(400., 252.));
|
||||
// This fixture asks for exactly +20 world units. A fractional start near a
|
||||
// screen-coordinate exponent boundary need not retain an exact +10 delta.
|
||||
let start = Point::new(projected.x.round(), projected.y.round());
|
||||
let mut geometry = resolved(&app);
|
||||
let camera = app.behavior.scenario.presentation.camera(geometry.bounds);
|
||||
assert_eq!(camera.scale, 0.5);
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let world = Point::new(
|
||||
(start.x - bounds.x - camera.pan.x) / camera.scale,
|
||||
(start.y - bounds.y - camera.pan.y) / camera.scale,
|
||||
);
|
||||
geometry.scene.scale = camera.scale;
|
||||
geometry.scene.camera_pan = camera.pan;
|
||||
let hit = geometry.scene.hit_item(world).unwrap();
|
||||
assert_eq!(hit.id, target);
|
||||
assert_eq!(hit.role, crate::behavior::SceneHitRole::MessageBody);
|
||||
let end = start + Vector::new(0., 10.); // .5 display scale: +20 authored y.
|
||||
assert_eq!(end.y - start.y, 10.);
|
||||
assert!(bounds.contains(start) && bounds.contains(end));
|
||||
let messages = 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;
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| matches!(
|
||||
m,
|
||||
Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin { .. }))
|
||||
))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
messages
|
||||
.iter()
|
||||
.filter(|m| matches!(
|
||||
m,
|
||||
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,
|
||||
"Only Saved A's addressed y changes; steps, other occurrences and B/C are exact"
|
||||
);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert_eq!(app.selected, target);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_reopens_exactly(&app);
|
||||
let 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_header_preview_survives_frames_and_freezes_fit_while_actual_bounds_expand() {
|
||||
let (mut app, mut mounted, diagram) = setup(true).await;
|
||||
let assessor = id(&app, "assessor");
|
||||
let before = app.document.snapshot();
|
||||
let original = resolved(&app);
|
||||
let original_camera = app.behavior.scenario.presentation.camera(original.bounds);
|
||||
let stored = stored_camera(&app);
|
||||
assert!(stored.2, "test must exercise original Fit");
|
||||
let start = screen(&mut mounted, &app, Point::new(640., 45.));
|
||||
press(&mut mounted, &mut app, start).await;
|
||||
let token = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_token()
|
||||
.expect("actual header press begins");
|
||||
for x in [670., 700.] {
|
||||
let end = start + Vector::new((x - 640.) * original_camera.scale, 0.);
|
||||
move_to(&mut mounted, &mut app, end).await;
|
||||
mounted.settle(&mut app).await; // Actual borrowed Scene/Stack tree is rebuilt.
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(token));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(stored_camera(&app), stored);
|
||||
let paint = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
|
||||
.unwrap();
|
||||
assert!(paint.refusal.is_none());
|
||||
assert!((paint.scene.render_lanes[&assessor] - x).abs() < 0.001);
|
||||
assert!(paint.bounds.width > original.bounds.width);
|
||||
assert_eq!(paint.scene.scale, original_camera.scale);
|
||||
assert_eq!(paint.scene.camera_pan, original_camera.pan);
|
||||
let header = paint.scene.boxes.iter().find(|b| b.id == assessor).unwrap();
|
||||
let displayed = Vector::new(header.rect.center().x, header.rect.center().y)
|
||||
* paint.scene.scale
|
||||
+ paint.scene.camera_pan;
|
||||
let baseline = Vector::new(640., 50.) * original_camera.scale + original_camera.pan;
|
||||
assert!((displayed.x - baseline.x - (x - 640.) * original_camera.scale).abs() < 0.001);
|
||||
assert_eq!(displayed.y, baseline.y);
|
||||
}
|
||||
let end = start + Vector::new(60. * original_camera.scale, 0.);
|
||||
release(&mut mounted, &mut app, end).await;
|
||||
let final_geometry = resolved(&app);
|
||||
let actual_x = final_geometry.scene.render_lanes[&assessor];
|
||||
assert!((actual_x - 700.).abs() < 0.001);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
set_expected_lane(&mut expected, diagram, assessor, actual_x);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let final_camera = app
|
||||
.behavior
|
||||
.scenario
|
||||
.presentation
|
||||
.camera(final_geometry.bounds);
|
||||
assert_eq!(
|
||||
final_camera, original_camera,
|
||||
"commit must not refit underneath pointer"
|
||||
);
|
||||
assert!(!app.behavior.scenario.presentation.fit);
|
||||
assert_eq!(app.selected, assessor);
|
||||
assert_reopens_exactly(&app);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_body_and_header_clicks_select_without_materializing_spacing() {
|
||||
let (mut app, mut mounted, _) = setup(false).await;
|
||||
let before = app.document.snapshot();
|
||||
let original_camera = stored_camera(&app);
|
||||
for (alias, world) in [
|
||||
("updated_report", Point::new(400., 252.)),
|
||||
("assessor", Point::new(640., 45.)),
|
||||
] {
|
||||
let target = id(&app, alias);
|
||||
let point = screen(&mut mounted, &app, world);
|
||||
mounted.click(&mut app, point).await;
|
||||
assert_eq!(app.selected, target);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(stored_camera(&app), original_camera);
|
||||
}
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_valid_preview_then_refused_self_call_never_commits_last_valid_candidate() {
|
||||
let (mut app, mut mounted, _) = setup(false).await;
|
||||
let target = id(&app, "assess_call");
|
||||
let before = app.document.snapshot();
|
||||
let camera = stored_camera(&app);
|
||||
let start = screen(&mut mounted, &app, Point::new(680., 344.));
|
||||
press(&mut mounted, &mut app, start).await;
|
||||
let valid = start + Vector::new(0., -10.); // send324, receive348, reply436 unchanged.
|
||||
move_to(&mut mounted, &mut app, valid).await;
|
||||
mounted.settle(&mut app).await;
|
||||
let paint = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
|
||||
.unwrap();
|
||||
assert!(paint.refusal.is_none());
|
||||
let line = paint.scene.lines.iter().find(|l| l.id == target).unwrap();
|
||||
assert_eq!(line.points[0].y, 324.);
|
||||
assert_eq!(line.points.last().unwrap().y, 348.);
|
||||
let refused = start + Vector::new(0., 53.); // send450, receive474 would follow reply436.
|
||||
move_to(&mut mounted, &mut app, refused).await;
|
||||
mounted.settle(&mut app).await;
|
||||
let paint = app
|
||||
.behavior
|
||||
.scenario
|
||||
.spacing_paint(app.scenario_context().unwrap(), app.camera_epoch)
|
||||
.unwrap();
|
||||
assert!(paint.refusal.is_some());
|
||||
assert_eq!(
|
||||
paint
|
||||
.scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|l| l.id == target)
|
||||
.unwrap()
|
||||
.points[0]
|
||||
.y,
|
||||
344.
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
release(&mut mounted, &mut app, refused).await;
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_escape_and_outside_pointer_cancel_actual_held_preview() {
|
||||
for escape in [true, false] {
|
||||
let (mut app, mut mounted, _) = setup(false).await;
|
||||
let before = app.document.snapshot();
|
||||
let camera = stored_camera(&app);
|
||||
let start = screen(&mut mounted, &app, Point::new(400., 252.));
|
||||
let end = start + Vector::new(0., 10.);
|
||||
press(&mut mounted, &mut app, start).await;
|
||||
move_to(&mut mounted, &mut app, end).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.behavior.scenario.spacing_token().is_some());
|
||||
let release_at = if escape {
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(iced::keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.await;
|
||||
end
|
||||
} else {
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let outside = Point::new(bounds.x - 5., bounds.y + 100.);
|
||||
move_to(&mut mounted, &mut app, outside).await;
|
||||
outside
|
||||
};
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
release(&mut mounted, &mut app, release_at).await;
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_drag_return_to_origin_preserves_redo_selection_and_original_fit_camera() {
|
||||
let (mut app, mut mounted, _) = setup(true).await;
|
||||
let owner = id(&app, "incident_response");
|
||||
app.document
|
||||
.execute(oa_core::Command::Rename(
|
||||
owner,
|
||||
"Previously edited owner".into(),
|
||||
))
|
||||
.unwrap();
|
||||
let redo = app.document.snapshot();
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.document.can_redo());
|
||||
let before = app.document.snapshot();
|
||||
let camera = stored_camera(&app);
|
||||
let selected = app.selected;
|
||||
let revision = app.document.revision();
|
||||
let start = screen(&mut mounted, &app, Point::new(400., 252.));
|
||||
press(&mut mounted, &mut app, start).await;
|
||||
move_to(&mut mounted, &mut app, start + Vector::new(0., 12.)).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.behavior.scenario.spacing_token().is_some());
|
||||
move_to(&mut mounted, &mut app, start).await;
|
||||
mounted.settle(&mut app).await;
|
||||
release(&mut mounted, &mut app, start).await;
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(stored_camera(&app), camera);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(app.document.can_redo());
|
||||
let task = app.update(Message::Redo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &redo.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &redo.bundle);
|
||||
}
|
||||
@@ -109,8 +109,28 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
mounted.pan(&mut app, start, start + delta).await;
|
||||
let diff = app.behavior.scenario.presentation.pan - next_pan;
|
||||
assert!(diff.x.hypot(diff.y) < 0.01);
|
||||
let tool = mounted.geometry(&app).bounds("behavior-tool:A").center();
|
||||
mounted.click(&mut app, tool).await;
|
||||
// Iced operations report palette children in content coordinates.
|
||||
// The preceding Action focus may have scrolled this retained palette.
|
||||
let geometry = mounted.geometry(&app);
|
||||
let scope = canvas_workspace::Scope::current(&app).unwrap();
|
||||
let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")];
|
||||
let tool = geometry.bounds("behavior-tool:A").center() - offset;
|
||||
assert!(
|
||||
palette.contains(tool),
|
||||
"visible Action at {tool:?}: {palette:?}"
|
||||
);
|
||||
let chosen = mounted.click(&mut app, tool).await;
|
||||
assert!(
|
||||
chosen.iter().any(|message| matches!(
|
||||
canvas_workspace::tests::intent_action(message),
|
||||
Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action)))
|
||||
)),
|
||||
"Action palette click at scale {scale}, offset {offset:?}: {chosen:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.canvas_tool(f.owner),
|
||||
CanvasTool::Add(Tool::Action)
|
||||
);
|
||||
let messages = mounted.click(&mut app, bounds.position() + desired).await;
|
||||
assert!(
|
||||
messages.iter().any(
|
||||
@@ -118,7 +138,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
tool: Tool::Action, slot, lifeline: Some(line), ..
|
||||
}) if *slot == expected && *line == f.sender)
|
||||
),
|
||||
"scale {scale}: {messages:?}"
|
||||
"scale {scale}, expected slot {expected:?}: {messages:?}"
|
||||
);
|
||||
let session = app.behavior.scenario.form_session().unwrap();
|
||||
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
|
||||
@@ -1400,7 +1420,18 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let point = bounds.position() + iced::Vector::new(95., 90.);
|
||||
let lifetime = app.camera_epoch;
|
||||
let pan = app.behavior.scenario.presentation.pan;
|
||||
// Fit stores no pan offset. A real drag starts from the displayed fitted
|
||||
// camera, then materializes it before applying its ordered screen deltas.
|
||||
let shown = app
|
||||
.behavior
|
||||
.scenario
|
||||
.display(app.project(), f.owner, app.selected)
|
||||
.unwrap();
|
||||
let before_camera = app
|
||||
.behavior
|
||||
.scenario
|
||||
.presentation
|
||||
.camera(shown.bounds().unwrap());
|
||||
let first = point + iced::Vector::new(13., 9.);
|
||||
let last = first + iced::Vector::new(17., -3.);
|
||||
let emitted = mounted
|
||||
@@ -1415,7 +1446,21 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
mouse::Cursor::Available(last),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point));
|
||||
let expected_pan = before_camera.pan + (last - point);
|
||||
let actual_pan = app.behavior.scenario.presentation.pan;
|
||||
let difference = actual_pan - expected_pan;
|
||||
assert!(
|
||||
difference.x.hypot(difference.y) < 0.001,
|
||||
"{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.zoom,
|
||||
Some(before_camera.scale)
|
||||
);
|
||||
assert!(
|
||||
!app.behavior.scenario.presentation.fit,
|
||||
"drag materializes the visible Fit camera"
|
||||
);
|
||||
assert_eq!(
|
||||
app.camera_epoch, lifetime,
|
||||
"own ordered deltas retain their lifetime"
|
||||
@@ -1820,14 +1865,19 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
|
||||
assert!(!presentation.viewport(Size::new(484., 204.)));
|
||||
let scale = presentation.scale(graph);
|
||||
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
|
||||
assert!(scale > MIN_SCALE);
|
||||
assert!(scale > camera::MIN_SCALE);
|
||||
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
|
||||
assert!(!presentation.zoom(Some(bad)));
|
||||
assert!(!presentation.viewport(Size::new(bad, 100.)));
|
||||
assert!(!presentation.viewport(Size::new(100., bad)));
|
||||
assert_eq!(presentation.scale(graph), scale);
|
||||
}
|
||||
assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE);
|
||||
let tall = Rectangle::with_size(Size::new(900., 10000.));
|
||||
let camera = presentation.camera(tall);
|
||||
assert!(camera.scale < 0.05 && camera.scale > 0.);
|
||||
assert!((camera.scale - 188. / 10000.).abs() < 1e-8);
|
||||
assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4);
|
||||
assert!((camera.pan.y - 8.).abs() < 1e-4);
|
||||
assert!(presentation.zoom(Some(1.25)));
|
||||
presentation.viewport(Size::new(200., 100.));
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! Actual Scene primitives, with independently chosen points and semantic IDs.
|
||||
//! These preserve the existing label hit band; they do not establish ink bounds
|
||||
//! or authoring eligibility, and do not dispatch a spacing gesture.
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
sender: Id,
|
||||
receiver: Id,
|
||||
call: Id,
|
||||
reply: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let project = language::parse(r#"arcadia 7;
|
||||
project hits "Hit roles" {
|
||||
oa_activity source "Source" {}
|
||||
oa_activity target "Target" {}
|
||||
interaction exchange "Contract" { source @source; target @target; }
|
||||
oa_scenario conversation "Conversation" { viewpoint OperationalActivity; steps [@call, @reply]; }
|
||||
oa_lifeline left "Left" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" { parent @conversation; represented @target; }
|
||||
oa_occurrence call "Call" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Call; }; }; }
|
||||
oa_occurrence reply "Reply" { parent @conversation; body { Exchange { exchange @exchange; sender @right; receiver @left; message_kind Reply; reply_to @call; }; }; }
|
||||
}
|
||||
"#).unwrap();
|
||||
let id = |alias: &str| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
Fixture {
|
||||
owner: id("conversation"),
|
||||
sender: id("left"),
|
||||
receiver: id("right"),
|
||||
call: id("call"),
|
||||
reply: id("reply"),
|
||||
exchange: id("exchange"),
|
||||
project,
|
||||
}
|
||||
}
|
||||
|
||||
fn scene(f: &Fixture) -> Scene {
|
||||
let document =
|
||||
Document::from_project(f.project.clone(), source_project::ProjectLimits::default())
|
||||
.unwrap();
|
||||
let before = document.snapshot();
|
||||
let result = Scene::sequence_geometry(
|
||||
document.project(),
|
||||
f.owner,
|
||||
f.call,
|
||||
Some(&BTreeMap::from([(f.sender, 160.), (f.receiver, 400.)])),
|
||||
None,
|
||||
);
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &before.bundle);
|
||||
assert_eq!(document.revision(), 0);
|
||||
assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
|
||||
result
|
||||
}
|
||||
|
||||
fn hit(scene: &Scene, point: Point, id: Id, role: SceneHitRole) {
|
||||
assert_eq!(scene.hit_item(point), Some(SceneHit { id, role }));
|
||||
assert_eq!(scene.hit(point), Some(id));
|
||||
}
|
||||
|
||||
fn occurrence(p: &mut Project, parent: Id, body: OccurrenceBody) -> Id {
|
||||
let mut element = p
|
||||
.make_element(Kind::Occurrence, Layer::Oa, "Hit target", Some(parent))
|
||||
.unwrap();
|
||||
let id = element.id;
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
p.elements.insert(id, element);
|
||||
p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
|
||||
fn exchange_mut(p: &mut Project, id: Id) -> &mut OccurrenceBody {
|
||||
let SharedBody::Occurrence { body, .. } =
|
||||
p.elements.get_mut(&id).unwrap().shared_mut().unwrap()
|
||||
else {
|
||||
panic!("expected occurrence")
|
||||
};
|
||||
body
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_headers_include_unassigned_lifelines_but_guides_are_not_headers() {
|
||||
let mut f = fixture();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.receiver)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Lifeline { represented: None };
|
||||
let s = scene(&f);
|
||||
assert!(!s.lanes.contains_key(&f.receiver));
|
||||
assert_eq!(s.render_lanes[&f.receiver], 400.);
|
||||
for (id, x) in [(f.sender, 160.), (f.receiver, 400.)] {
|
||||
// The actual header is x-102..x+102, y15..85, including its padding.
|
||||
for point in [Point::new(x - 101., 16.), Point::new(x + 101., 84.)] {
|
||||
hit(&s, point, id, SceneHitRole::LifelineHeader);
|
||||
}
|
||||
hit(&s, Point::new(x, 95.), id, SceneHitRole::LifelineGuide);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_exchange_occurrences_keep_distinct_message_body_and_label_hits() {
|
||||
let f = fixture();
|
||||
let s = scene(&f);
|
||||
assert_eq!(s.rows[&f.call].1, 148.);
|
||||
assert_eq!(s.rows[&f.reply].1, 240.);
|
||||
for (id, y) in [(f.call, 148.), (f.reply, 240.)] {
|
||||
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(180., y - 16.),
|
||||
id,
|
||||
SceneHitRole::MessageLabel,
|
||||
);
|
||||
assert_ne!(id, f.exchange);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_elbow_and_receive_endpoint_are_messages_not_execution_hits() {
|
||||
let mut f = fixture();
|
||||
for id in [f.call, f.reply] {
|
||||
let OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
exchange,
|
||||
..
|
||||
} = exchange_mut(&mut f.project, id)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*sender = Some(f.sender);
|
||||
*receiver = Some(f.sender);
|
||||
*exchange = None;
|
||||
}
|
||||
let s = scene(&f);
|
||||
let call = s.lines.iter().find(|l| l.id == f.call).unwrap();
|
||||
assert_eq!(call.points.len(), 4);
|
||||
assert_eq!(call.points[0].y, 148.);
|
||||
assert_eq!(call.points[3].y, 172.);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(call.points[1].x, 160.),
|
||||
f.call,
|
||||
SceneHitRole::MessageBody,
|
||||
);
|
||||
hit(&s, call.points[3], f.call, SceneHitRole::MessageBody);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(180., 132.),
|
||||
f.call,
|
||||
SceneHitRole::MessageLabel,
|
||||
);
|
||||
let bar = s.activations.iter().find(|a| a.call == f.call).unwrap();
|
||||
hit(
|
||||
&s,
|
||||
Point::new(bar.rect.x + 2., 190.),
|
||||
f.call,
|
||||
SceneHitRole::Activation,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_shoulder_beats_another_calls_label_and_broad_message_tolerance() {
|
||||
let mut f = fixture();
|
||||
let mut inner = f.project.elements[&f.call].clone();
|
||||
inner.id = Id::new_v4();
|
||||
inner.alias = "inner_call_hit".into();
|
||||
inner.name = "Pressure verification with a descriptive operation label".into();
|
||||
let inner_id = inner.id;
|
||||
f.project.elements.insert(inner_id, inner);
|
||||
let mut inner_reply = f.project.elements[&f.reply].clone();
|
||||
inner_reply.id = Id::new_v4();
|
||||
inner_reply.alias = "inner_reply_hit".into();
|
||||
let reply_id = inner_reply.id;
|
||||
f.project.elements.insert(reply_id, inner_reply);
|
||||
let OccurrenceBody::Exchange { reply_to, .. } = exchange_mut(&mut f.project, reply_id) else {
|
||||
unreachable!()
|
||||
};
|
||||
*reply_to = Some(inner_id);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![f.call, inner_id, reply_id, f.reply];
|
||||
let mut s = scene(&f);
|
||||
let inner = s.lines.iter().find(|l| l.id == inner_id).unwrap();
|
||||
let outer = s.activations.iter().find(|a| a.call == f.call).unwrap();
|
||||
let shoulder = Point::new(outer.rect.x + 1., 225.);
|
||||
assert!(outer.rect.contains(shoulder));
|
||||
assert!(inner.label_bounds().contains(shoulder));
|
||||
let endpoint = *inner.points.last().unwrap();
|
||||
for scale in [1., 0.05, 0.0003] {
|
||||
s.scale = scale;
|
||||
hit(&s, shoulder, f.call, SceneHitRole::Activation);
|
||||
hit(&s, endpoint, inner_id, SceneHitRole::MessageBody);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(180., 225.),
|
||||
inner_id,
|
||||
SceneHitRole::MessageLabel,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_delete_markers_are_distinct_from_their_own_message_body_and_label() {
|
||||
let mut f = fixture();
|
||||
for (id, kind) in [
|
||||
(f.call, MessageKind::Create),
|
||||
(f.reply, MessageKind::Delete),
|
||||
] {
|
||||
let OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
} = exchange_mut(&mut f.project, id)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*sender = Some(f.sender);
|
||||
*receiver = Some(f.receiver);
|
||||
*message_kind = kind;
|
||||
*reply_to = None;
|
||||
}
|
||||
let s = scene(&f);
|
||||
for (id, y, label_y) in [(f.call, 148., 118.), (f.reply, 240., 224.)] {
|
||||
hit(&s, Point::new(400., y), id, SceneHitRole::LifecycleMarker);
|
||||
hit(&s, Point::new(280., y), id, SceneHitRole::MessageBody);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(180., label_y),
|
||||
id,
|
||||
SceneHitRole::MessageLabel,
|
||||
);
|
||||
}
|
||||
// Capsule boundary owns its contact with the incoming Create arrow.
|
||||
hit(
|
||||
&s,
|
||||
Point::new(330., 148.),
|
||||
f.call,
|
||||
SceneHitRole::LifecycleMarker,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_scale_nearest_hit_retains_the_winning_marker_or_message_role() {
|
||||
let mut f = fixture();
|
||||
let OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
reply_to,
|
||||
..
|
||||
} = exchange_mut(&mut f.project, f.reply)
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*sender = Some(f.sender);
|
||||
*receiver = Some(f.receiver);
|
||||
*message_kind = MessageKind::Delete;
|
||||
*reply_to = None;
|
||||
let mut s = scene(&f);
|
||||
s.scale = 0.0003;
|
||||
// Both are outside exact strokes/labels. Comparing all broad candidates
|
||||
// chooses the actual nearer shape, even when their UUID is identical.
|
||||
hit(
|
||||
&s,
|
||||
Point::new(280., 246.),
|
||||
f.reply,
|
||||
SceneHitRole::MessageBody,
|
||||
);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(400., 265.),
|
||||
f.reply,
|
||||
SceneHitRole::LifecycleMarker,
|
||||
);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(280., 154.),
|
||||
f.call,
|
||||
SceneHitRole::MessageBody,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_frame_and_use_boxes_do_not_advertise_message_or_header_roles() {
|
||||
for body in [
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: None,
|
||||
bindings: vec![],
|
||||
},
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
operation: None,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
] {
|
||||
let mut f = fixture();
|
||||
let id = occurrence(&mut f.project, f.owner, body);
|
||||
let s = scene(&f);
|
||||
let item = s.boxes.iter().find(|b| b.id == id).unwrap();
|
||||
hit(
|
||||
&s,
|
||||
item.rect.position() + iced::Vector::new(15., 15.),
|
||||
id,
|
||||
SceneHitRole::Box,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winning_primitive_role_does_not_come_from_its_shared_uuid() {
|
||||
let f = fixture();
|
||||
let mut s = scene(&f);
|
||||
// A controlled overlapping primitive verifies reverse box order without
|
||||
// inventing a core alias. Its ID intentionally equals a real header's ID.
|
||||
s.boxes.push(BoxItem {
|
||||
id: f.sender,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(150., 20.), Size::new(20., 20.)),
|
||||
title: "Overlay control".into(),
|
||||
detail: String::new(),
|
||||
marker: None,
|
||||
});
|
||||
hit(&s, Point::new(160., 30.), f.sender, SceneHitRole::Box);
|
||||
hit(
|
||||
&s,
|
||||
Point::new(180., 30.),
|
||||
f.sender,
|
||||
SceneHitRole::LifelineHeader,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_points_never_produce_a_semantic_or_spacing_hit() {
|
||||
let f = fixture();
|
||||
let s = scene(&f);
|
||||
for point in [
|
||||
Point::new(f32::NAN, 148.),
|
||||
Point::new(280., f32::NAN),
|
||||
Point::new(f32::INFINITY, 148.),
|
||||
Point::new(280., f32::NEG_INFINITY),
|
||||
] {
|
||||
assert_eq!(s.hit_item(point), None);
|
||||
assert_eq!(s.hit(point), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
|
||||
//!
|
||||
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
|
||||
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
|
||||
//! This adapter uses that same allocator/font system/attributes, not a character
|
||||
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
|
||||
//! Experimental parity requires settled fonts and a fresh renderer/cache after
|
||||
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
|
||||
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
|
||||
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
|
||||
//! No fonts may be loaded between measurement, guarded access and actual draw.
|
||||
//!
|
||||
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
|
||||
//! supplies a usable text width in world units and retains one cache per live
|
||||
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
|
||||
//! consume the returned Label. Position/color do not invalidate measurement.
|
||||
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
|
||||
//! It measures layout advances, not raster ink; overhang/clip/activation-border
|
||||
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
|
||||
//! renderer is deliberately untouched for a later baseline comparison.
|
||||
use iced::advanced::{
|
||||
graphics::text as graphics_text,
|
||||
text::{Alignment, LineHeight, Shaping},
|
||||
};
|
||||
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
|
||||
use std::{collections::VecDeque, ops::Range, sync::Arc};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
const MAX_SOURCE_BYTES: usize = 64 * 1024;
|
||||
const MAX_GRAPHEMES: usize = 4096;
|
||||
const MAX_ENTRIES: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Style {
|
||||
pub font: Font,
|
||||
pub size: Pixels,
|
||||
pub line_height: LineHeight,
|
||||
pub shaping: Shaping,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
let text = Text::default();
|
||||
Self {
|
||||
font: text.font,
|
||||
size: Pixels(12.),
|
||||
line_height: text.line_height,
|
||||
shaping: text.shaping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
fn scaled(self, zoom: f32) -> (f32, f32) {
|
||||
let size = Pixels(self.size.0 * zoom);
|
||||
let line_height = match self.line_height {
|
||||
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
|
||||
LineHeight::Relative(value) => LineHeight::Relative(value),
|
||||
};
|
||||
(size.0, line_height.to_absolute(size).0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit experimental contract: the caller finishes loading fonts and starts
|
||||
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
|
||||
/// This module cannot inspect or reset the actual renderer's text cache.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct RenderSession {
|
||||
version: graphics_text::Version,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
|
||||
impl RenderSession {
|
||||
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
|
||||
let fonts = graphics_text::font_system()
|
||||
.read()
|
||||
.map_err(|_| Error::FontSystemUnavailable)?;
|
||||
Ok(Self {
|
||||
version: fonts.version(),
|
||||
identity: Arc::new(()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Label {
|
||||
source: String,
|
||||
displayed: String,
|
||||
/// Original-label bytes represented before any added ellipsis. Includes
|
||||
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
|
||||
retained_source: Range<usize>,
|
||||
/// World-space layout advances, at origin; not glyph-ink containment.
|
||||
bounds: Rectangle,
|
||||
style: Style,
|
||||
measured_session: RenderSession,
|
||||
measured_zoom: f32,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
|
||||
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
|
||||
return Err(Error::StaleRenderSession);
|
||||
}
|
||||
if !zoom.is_finite() || zoom != self.measured_zoom {
|
||||
return Err(Error::StaleZoom);
|
||||
}
|
||||
let fonts = graphics_text::font_system()
|
||||
.read()
|
||||
.map_err(|_| Error::FontSystemUnavailable)?;
|
||||
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
|
||||
return Err(Error::FontSetChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
|
||||
self.check(session, zoom)?;
|
||||
Ok(&self.displayed)
|
||||
}
|
||||
|
||||
pub fn retained_source(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
) -> Result<Range<usize>, Error> {
|
||||
self.check(session, zoom)?;
|
||||
Ok(self.retained_source.clone())
|
||||
}
|
||||
|
||||
pub fn retains_any(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
field: Range<usize>,
|
||||
) -> Result<bool, Error> {
|
||||
self.check(session, zoom)?;
|
||||
if field.start > field.end || self.source.get(field.clone()).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let end = field.end.min(self.retained_source.end);
|
||||
Ok(field.start < end
|
||||
&& self
|
||||
.source
|
||||
.get(field.start..end)
|
||||
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
|
||||
}
|
||||
|
||||
/// Validate finite world position and rectangle endpoints only. Camera
|
||||
/// transforms and raster bounds remain a separate integration contract.
|
||||
pub fn bounds_at(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
position: Point,
|
||||
) -> Result<Rectangle, Error> {
|
||||
self.check(session, zoom)?;
|
||||
translated_bounds(position, self.bounds.size())
|
||||
}
|
||||
|
||||
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
|
||||
/// No per-label wrapping or implicit text maximum is introduced.
|
||||
/// The returned Text must be consumed immediately in this session/zoom;
|
||||
/// retaining raw Text past another font load/zoom change bypasses the guard.
|
||||
pub fn canvas_text(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
position: Point,
|
||||
color: Color,
|
||||
) -> Result<Text, Error> {
|
||||
self.bounds_at(session, zoom, position)?;
|
||||
Ok(Text {
|
||||
content: self.displayed.clone(),
|
||||
position,
|
||||
color,
|
||||
size: self.style.size,
|
||||
line_height: self.style.line_height,
|
||||
font: self.style.font,
|
||||
shaping: self.style.shaping,
|
||||
max_width: f32::INFINITY,
|
||||
..Text::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Error {
|
||||
InvalidMetrics,
|
||||
SourceBudget,
|
||||
GraphemeBudget,
|
||||
FontSystemUnavailable,
|
||||
InvalidMeasurement,
|
||||
InvalidPosition,
|
||||
StaleZoom,
|
||||
StaleRenderSession,
|
||||
FontSetChanged,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
source: String,
|
||||
style: Style,
|
||||
zoom: f32,
|
||||
width: f32,
|
||||
label: Arc<Label>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
|
||||
/// would thrash when a larger Scene is rebuilt in order. The integration owner
|
||||
/// must retain these by occurrence identity and prune on removal/document change.
|
||||
pub(super) struct Cache {
|
||||
version: Option<graphics_text::Version>,
|
||||
session: Option<Arc<()>>,
|
||||
entries: VecDeque<Entry>,
|
||||
#[cfg(test)]
|
||||
allocations: usize,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
|
||||
/// Cache ownership must survive redraws; constructing one per Scene defeats
|
||||
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
|
||||
/// a fresh renderer and a new settled session, not just remeasure this cache.
|
||||
/// Error handling/fallback for these explicit experiment budgets is an
|
||||
/// integration decision; never silently paint the old truncated contract.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
session: &RenderSession,
|
||||
source: &str,
|
||||
width: f32,
|
||||
zoom: f32,
|
||||
style: Style,
|
||||
) -> Result<Arc<Label>, Error> {
|
||||
let (size, line_height) = style.scaled(zoom);
|
||||
if !width.is_finite()
|
||||
|| width < 0.
|
||||
|| !zoom.is_finite()
|
||||
|| zoom <= 0.
|
||||
|| !size.is_finite()
|
||||
|| size <= 0.
|
||||
|| !line_height.is_finite()
|
||||
|| line_height <= 0.
|
||||
|| !(width * zoom).is_finite()
|
||||
|| !(size / zoom).is_finite()
|
||||
|| !(line_height / zoom).is_finite()
|
||||
{
|
||||
return Err(Error::InvalidMetrics);
|
||||
}
|
||||
if source.len() > MAX_SOURCE_BYTES {
|
||||
return Err(Error::SourceBudget);
|
||||
}
|
||||
let mut fonts = graphics_text::font_system()
|
||||
.write()
|
||||
.map_err(|_| Error::FontSystemUnavailable)?;
|
||||
if session.version != fonts.version() {
|
||||
return Err(Error::FontSetChanged);
|
||||
}
|
||||
if self.version != Some(fonts.version())
|
||||
|| self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
|
||||
{
|
||||
self.entries.clear();
|
||||
self.version = Some(fonts.version());
|
||||
self.session = Some(session.identity.clone());
|
||||
}
|
||||
if let Some(index) = self.entries.iter().position(|entry| {
|
||||
entry.source == source
|
||||
&& entry.style == style
|
||||
&& entry.zoom == zoom
|
||||
&& entry.width == width
|
||||
}) {
|
||||
let entry = self.entries.remove(index).unwrap();
|
||||
let label = entry.label.clone();
|
||||
self.entries.push_back(entry);
|
||||
return Ok(label);
|
||||
}
|
||||
let normalized = Normalized::new(source);
|
||||
let cuts: Vec<usize> = normalized
|
||||
.text
|
||||
.grapheme_indices(true)
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(normalized.text.len()))
|
||||
.collect();
|
||||
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
|
||||
return Err(Error::GraphemeBudget);
|
||||
}
|
||||
// Candidate buffers are temporary. Only the resolved strings/metrics
|
||||
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
|
||||
let mut shapes = graphics_text::Cache::new();
|
||||
let mut measure = |content: &str| -> Result<Size, Error> {
|
||||
if content.is_empty() {
|
||||
return Ok(Size::ZERO);
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.allocations += 1;
|
||||
}
|
||||
let (_, entry) = shapes.allocate(
|
||||
fonts.raw(),
|
||||
graphics_text::cache::Key {
|
||||
content,
|
||||
size,
|
||||
line_height,
|
||||
font: style.font,
|
||||
bounds: Size::INFINITE,
|
||||
shaping: style.shaping,
|
||||
align_x: Alignment::Default,
|
||||
},
|
||||
);
|
||||
if entry.buffer.layout_runs().count() != 1
|
||||
|| !entry.min_bounds.width.is_finite()
|
||||
|| !entry.min_bounds.height.is_finite()
|
||||
{
|
||||
return Err(Error::InvalidMeasurement);
|
||||
}
|
||||
world_size(entry.min_bounds, zoom)
|
||||
};
|
||||
let full = measure(&normalized.text)?;
|
||||
let (displayed, retained, measured) = if full.width <= width {
|
||||
(normalized.text.clone(), normalized.text.len(), full)
|
||||
} else {
|
||||
let ellipsis = measure("…")?;
|
||||
if ellipsis.width > width {
|
||||
(String::new(), 0, Size::ZERO)
|
||||
} else {
|
||||
// Every accepted candidate is actually shaped and fits. Search
|
||||
// is bounded/logarithmic; contextual shaping is not universally
|
||||
// monotone, so this does NOT promise the globally longest prefix.
|
||||
// Full content is always checked first and never needlessly cut.
|
||||
let mut best = ("…".to_owned(), 0, ellipsis);
|
||||
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
|
||||
while low < high {
|
||||
let middle = low + (high - low) / 2;
|
||||
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
|
||||
let measured = measure(&candidate)?;
|
||||
if measured.width <= width {
|
||||
best = (candidate, cuts[middle], measured);
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
};
|
||||
let label = Arc::new(Label {
|
||||
source: source.into(),
|
||||
displayed,
|
||||
retained_source: 0..normalized.source_end(retained),
|
||||
bounds: Rectangle::with_size(measured),
|
||||
style,
|
||||
measured_session: session.clone(),
|
||||
measured_zoom: zoom,
|
||||
});
|
||||
if self.entries.len() == MAX_ENTRIES {
|
||||
self.entries.pop_front();
|
||||
}
|
||||
self.entries.push_back(Entry {
|
||||
source: source.into(),
|
||||
style,
|
||||
zoom,
|
||||
width,
|
||||
label: label.clone(),
|
||||
});
|
||||
Ok(label)
|
||||
}
|
||||
}
|
||||
|
||||
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
|
||||
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
|
||||
if !world.width.is_finite()
|
||||
|| !world.height.is_finite()
|
||||
|| world.width < 0.
|
||||
|| world.height < 0.
|
||||
{
|
||||
return Err(Error::InvalidMeasurement);
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
|
||||
if !position.x.is_finite()
|
||||
|| !position.y.is_finite()
|
||||
|| !(position.x + size.width).is_finite()
|
||||
|| !(position.y + size.height).is_finite()
|
||||
{
|
||||
return Err(Error::InvalidPosition);
|
||||
}
|
||||
Ok(Rectangle::new(position, size))
|
||||
}
|
||||
|
||||
struct Normalized {
|
||||
text: String,
|
||||
ends: Vec<(usize, usize)>,
|
||||
}
|
||||
impl Normalized {
|
||||
fn new(source: &str) -> Self {
|
||||
let mut result = Self {
|
||||
text: String::new(),
|
||||
ends: vec![(0, 0)],
|
||||
};
|
||||
let mut separator = false;
|
||||
for (start, c) in source.char_indices() {
|
||||
if c.is_whitespace() {
|
||||
separator = !result.text.is_empty();
|
||||
continue;
|
||||
}
|
||||
if separator {
|
||||
result.text.push(' ');
|
||||
result.ends.push((result.text.len(), start));
|
||||
separator = false;
|
||||
}
|
||||
result.text.push(c);
|
||||
result.ends.push((result.text.len(), start + c.len_utf8()));
|
||||
}
|
||||
result
|
||||
}
|
||||
fn source_end(&self, normalized_end: usize) -> usize {
|
||||
self.ends
|
||||
.binary_search_by_key(&normalized_end, |(end, _)| *end)
|
||||
.map(|index| self.ends[index].1)
|
||||
.expect("grapheme cuts are character boundaries")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_label/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,231 @@
|
||||
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
|
||||
//!
|
||||
//! The caller supplies a finite structural label band AFTER endpoint/activation
|
||||
//! alignment, with padding already removed from its edges. Blockers include
|
||||
//! padded activation/card/header bounds over the complete text band, including
|
||||
//! stroke/ink clearance. This function does not infer a self-message corridor,
|
||||
//! alter scene extents, change zoom, or establish raster-ink containment.
|
||||
use iced::Rectangle;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct InvalidGeometry;
|
||||
|
||||
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
|
||||
let right = rect.x + rect.width;
|
||||
let bottom = rect.y + rect.height;
|
||||
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
|
||||
.into_iter()
|
||||
.any(|value| !value.is_finite())
|
||||
|| rect.width < 0.
|
||||
|| rect.height < 0.
|
||||
{
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
Ok((rect.x, right, rect.y, bottom))
|
||||
}
|
||||
|
||||
/// Select one connected clear interval; never assemble text across blockers.
|
||||
/// Prefer greater width, then proximity to the previous label anchor, then the
|
||||
/// leftmost interval. Input order has no influence. A zero-area band has no
|
||||
/// corridor. Touching the padded vertical band is conservatively obstructing;
|
||||
/// a zero-area blocker has no area and must receive stroke padding upstream.
|
||||
pub(super) fn widest_clear(
|
||||
band: Rectangle,
|
||||
blockers: &[Rectangle],
|
||||
preferred_anchor: f32,
|
||||
) -> Result<Option<Rectangle>, InvalidGeometry> {
|
||||
let (left, right, top, bottom) = edges(band)?;
|
||||
if !preferred_anchor.is_finite() {
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
let mut blocked = Vec::new();
|
||||
for &blocker in blockers {
|
||||
let (x0, x1, y0, y1) = edges(blocker)?;
|
||||
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
|
||||
let interval = (x0.max(left), x1.min(right));
|
||||
if interval.0 < interval.1 {
|
||||
blocked.push(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
if band.width == 0. || band.height == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut best: Option<Rectangle> = None;
|
||||
let mut consider = |start: f32, end: f32| {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let candidate = Rectangle {
|
||||
x: start,
|
||||
width: contained_width(start, end),
|
||||
..band
|
||||
};
|
||||
// Use f64 only for comparison distance: subtracting two admitted f32
|
||||
// world coordinates can overflow even though each rectangle is finite.
|
||||
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
|
||||
if best.is_none_or(|old| {
|
||||
candidate.width > old.width
|
||||
|| (candidate.width == old.width
|
||||
&& (distance(candidate.x) < distance(old.x)
|
||||
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
|
||||
}) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
};
|
||||
let mut cursor = left;
|
||||
for (start, end) in blocked {
|
||||
consider(cursor, start);
|
||||
cursor = cursor.max(end);
|
||||
}
|
||||
consider(cursor, right);
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// Finite f32 endpoints can subtract to infinity or round across a blocker.
|
||||
/// Compute in f64, cap to finite f32, and round inward if nearest rounding
|
||||
/// overshot the admitted interval's end.
|
||||
fn contained_width(start: f32, end: f32) -> f32 {
|
||||
let exact = f64::from(end) - f64::from(start);
|
||||
let mut width = exact.min(f64::from(f32::MAX)) as f32;
|
||||
if f64::from(start) + f64::from(width) > f64::from(end) {
|
||||
width = width.next_down();
|
||||
}
|
||||
width
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
|
||||
Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_message_does_not_cross_an_intermediate_execution() {
|
||||
let band = rect(20., 100., 700., 22.);
|
||||
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
|
||||
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
|
||||
assert_eq!(result, rect(20., 100., 230., 22.));
|
||||
for blocker in blockers {
|
||||
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
|
||||
let band = rect(-100., 10., 600., 20.);
|
||||
let blockers = [
|
||||
rect(50., 0., 80., 60.),
|
||||
rect(0., 5., 70., 30.),
|
||||
rect(450., 0., 100., 60.),
|
||||
];
|
||||
let expected = Some(rect(130., 10., 320., 20.));
|
||||
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
|
||||
let mut reversed = blockers;
|
||||
reversed.reverse();
|
||||
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
|
||||
let band = rect(0., 100., 200., 20.);
|
||||
// The first blocker ends at the band top: checking only a baseline
|
||||
// would miss it. The second starts at the band bottom.
|
||||
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 0.).unwrap(),
|
||||
Some(rect(60., 100., 90., 20.))
|
||||
);
|
||||
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
|
||||
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
|
||||
let band = rect(0., 0., 300., 20.);
|
||||
let blockers = [rect(100., 0., 100., 20.)];
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 190.).unwrap(),
|
||||
Some(rect(200., 0., 100., 20.))
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 100.).unwrap(),
|
||||
Some(rect(0., 0., 100., 20.))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
|
||||
// Direction and self-loop policy are explicit caller inputs. Neither
|
||||
// a zero endpoint gap nor screen width can silently supply a corridor.
|
||||
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
assert_eq!(
|
||||
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
|
||||
let band = rect(0., 0., 100., 20.);
|
||||
for invalid in [
|
||||
rect(f32::NAN, 0., 10., 20.),
|
||||
rect(0., 0., -1., 20.),
|
||||
rect(f32::MAX, 0., f32::MAX, 20.),
|
||||
rect(0., f32::MAX, 10., f32::MAX),
|
||||
] {
|
||||
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
|
||||
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_band_cannot_reconstruct_an_infinite_width() {
|
||||
let band = rect(f32::from_bits(0xfe79636e), 0., f32::MAX, 20.);
|
||||
let right = band.x + band.width;
|
||||
assert!(right.is_finite());
|
||||
assert!(
|
||||
(right - band.x).is_infinite(),
|
||||
"counterexample must exercise f32 subtraction overflow"
|
||||
);
|
||||
let result = widest_clear(band, &[], 0.).unwrap().unwrap();
|
||||
assert!(result.width.is_finite() && result.width > 0.);
|
||||
assert!(result.x + result.width <= right);
|
||||
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructed_width_rounds_inward_at_a_blocker() {
|
||||
let band = rect(-999_948.562_5, 0., 1_000_000., 20.);
|
||||
let blocker = rect(2.1, 0., 10., 20.);
|
||||
assert!(
|
||||
band.x + (blocker.x - band.x) > blocker.x,
|
||||
"counterexample must cross the blocker under naive reconstruction"
|
||||
);
|
||||
let result = widest_clear(band, &[blocker], 0.).unwrap().unwrap();
|
||||
assert_eq!(result.x, band.x);
|
||||
assert!(result.width > 999_000.);
|
||||
assert!(result.x + result.width <= blocker.x);
|
||||
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(blocker.x));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::text::Paragraph,
|
||||
text::{Paragraph as _, Text as ParagraphText, Wrapping},
|
||||
};
|
||||
|
||||
// Independent paragraph construction: no candidate cache, normalization, search,
|
||||
// or character-width estimate is used by this measurement oracle.
|
||||
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Relative(factor) => LineHeight::Relative(factor),
|
||||
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
|
||||
};
|
||||
let paragraph = Paragraph::with_text(ParagraphText {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size,
|
||||
line_height,
|
||||
font: style.font,
|
||||
align_x: Alignment::Default,
|
||||
align_y: iced::alignment::Vertical::Top,
|
||||
shaping: style.shaping,
|
||||
wrapping: Wrapping::default(),
|
||||
});
|
||||
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
|
||||
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style::default();
|
||||
for text in [
|
||||
"1. call · Request valve repair · Technician service conversation",
|
||||
"2. reply · Return pressure after repair · Technician service conversation",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel A",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel B",
|
||||
] {
|
||||
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
|
||||
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
|
||||
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
|
||||
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
|
||||
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
|
||||
assert_ne!(narrow.displayed, text);
|
||||
assert!(narrow.bounds.width <= 80.);
|
||||
assert_eq!(
|
||||
narrow.bounds.size(),
|
||||
paragraph_size(&narrow.displayed, 1., style)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
|
||||
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
|
||||
let expected = "1. call · Pump · Operation: Need\u{301}le";
|
||||
let n = Normalized::new(raw);
|
||||
assert_eq!(n.text, expected);
|
||||
let prefix = "1. call · Pump ";
|
||||
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let wide = cache
|
||||
.resolve(&session, raw, 1600., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(wide.displayed, expected);
|
||||
assert_eq!(wide.retained_source.end, raw.trim_end().len());
|
||||
let field = raw.find("Need").unwrap()..raw.trim_end().len();
|
||||
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
|
||||
assert!(
|
||||
!wide.retains_any(&session, 1., 0..2).unwrap(),
|
||||
"normalized whitespace is not a visible field"
|
||||
);
|
||||
let small = cache
|
||||
.resolve(&session, raw, 70., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!small.retains_any(&session, 1., field).unwrap());
|
||||
assert!(small.retained_source.end < raw.find("Operation").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
|
||||
let text = "call · 戻り値 圧力 e\u{301} 👩🔬 safety valve verification";
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for zoom in [0.66, 1., 1.75] {
|
||||
let label = cache
|
||||
.resolve(&session, text, 105., zoom, Style::default())
|
||||
.unwrap();
|
||||
assert!(label.bounds.width <= 105.);
|
||||
assert_eq!(
|
||||
label.bounds.size(),
|
||||
paragraph_size(&label.displayed, zoom, Style::default())
|
||||
);
|
||||
assert!(
|
||||
text.grapheme_indices(true)
|
||||
.any(|(i, _)| i == label.retained_source.end)
|
||||
|| label.retained_source.end == text.len()
|
||||
);
|
||||
let painted = label
|
||||
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
|
||||
.unwrap();
|
||||
assert_eq!(painted.content, label.displayed);
|
||||
assert!(painted.max_width.is_infinite());
|
||||
assert_eq!(
|
||||
label
|
||||
.bounds_at(&session, zoom, painted.position)
|
||||
.unwrap()
|
||||
.size(),
|
||||
label.bounds.size()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for text in ["", " \t\n", "Operation: Pump"] {
|
||||
let label = cache
|
||||
.resolve(&session, text, 0., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(label.displayed, "");
|
||||
assert_eq!(label.retained_source, 0..0);
|
||||
assert_eq!(label.bounds.size(), Size::ZERO);
|
||||
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification with a descriptive operation label";
|
||||
let style = Style::default();
|
||||
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
let measured = cache.allocations;
|
||||
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
for (width, zoom, style) in [
|
||||
(120., 1., style),
|
||||
(100., 0.66, style),
|
||||
(
|
||||
100.,
|
||||
1.,
|
||||
Style {
|
||||
size: Pixels(14.),
|
||||
..style
|
||||
},
|
||||
),
|
||||
] {
|
||||
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &other));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
|
||||
let mut cache = Cache::default();
|
||||
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification";
|
||||
let a = cache
|
||||
.resolve(&old_session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(
|
||||
&a,
|
||||
&cache
|
||||
.resolve(&old_session, text, 100., 1., Style::default())
|
||||
.unwrap()
|
||||
));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
// A fresh renderer session invalidates a still-resident entry. This does
|
||||
// not mutate global fonts or purport to test dynamic WGPU font loading.
|
||||
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let refreshed = cache
|
||||
.resolve(&new_session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.entries.front().unwrap().label,
|
||||
&refreshed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Resident pressure label";
|
||||
let a = cache
|
||||
.resolve(&session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
// Exercise the local memo epoch branch while a is still resident. No
|
||||
// intervening width/zoom/style resolution can cause incidental eviction.
|
||||
// This is not an actual font-load or renderer-cache acceptance test.
|
||||
cache.version = None;
|
||||
let refreshed = cache
|
||||
.resolve(&session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.entries.front().unwrap().label,
|
||||
&refreshed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache
|
||||
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
|
||||
.unwrap();
|
||||
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (context, zoom, expected) in [
|
||||
(&session, 0.66, Error::StaleZoom),
|
||||
(&other, 1., Error::StaleRenderSession),
|
||||
] {
|
||||
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(
|
||||
label.retains_any(context, zoom, 0..9).unwrap_err(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style {
|
||||
size: Pixels(2.),
|
||||
line_height: LineHeight::Relative(f32::MAX),
|
||||
..Style::default()
|
||||
};
|
||||
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
|
||||
// required world line height is not; reject before passing it to shaping.
|
||||
let scaled = style.scaled(0.5);
|
||||
assert!(scaled.0.is_finite() && scaled.1.is_finite());
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, "call", 100., 0.5, style)
|
||||
.unwrap_err(),
|
||||
Error::InvalidMetrics
|
||||
);
|
||||
assert_eq!(cache.allocations, 0);
|
||||
assert!(cache.entries.is_empty());
|
||||
// Independently exercise validation after division for actual measured
|
||||
// dimensions: finite scaled width/height do not imply finite world values.
|
||||
assert_eq!(
|
||||
world_size(Size::new(f32::MAX, 1.), 0.5),
|
||||
Err(Error::InvalidMeasurement)
|
||||
);
|
||||
assert_eq!(
|
||||
world_size(Size::new(1., f32::MAX), 0.5),
|
||||
Err(Error::InvalidMeasurement)
|
||||
);
|
||||
assert_eq!(
|
||||
world_size(Size::new(20., 8.), 0.5).unwrap(),
|
||||
Size::new(40., 16.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, "call", width, zoom, Style::default())
|
||||
.unwrap_err(),
|
||||
Error::InvalidMetrics
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&session,
|
||||
&"x".repeat(MAX_SOURCE_BYTES + 1),
|
||||
100.,
|
||||
1.,
|
||||
Style::default()
|
||||
)
|
||||
.unwrap_err(),
|
||||
Error::SourceBudget
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&session,
|
||||
&"x".repeat(MAX_GRAPHEMES + 1),
|
||||
100.,
|
||||
1.,
|
||||
Style::default()
|
||||
)
|
||||
.unwrap_err(),
|
||||
Error::GraphemeBudget
|
||||
);
|
||||
assert!(cache.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache
|
||||
.resolve(&session, "call", 100., 1., Style::default())
|
||||
.unwrap();
|
||||
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
|
||||
assert_eq!(
|
||||
label.bounds_at(&session, 1., position).unwrap_err(),
|
||||
Error::InvalidPosition
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(&session, 1., position, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
Error::InvalidPosition
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
|
||||
Err(Error::InvalidPosition)
|
||||
);
|
||||
assert_eq!(
|
||||
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
|
||||
Err(Error::InvalidPosition)
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.bounds_at(&session, 1., Point::new(-20., -10.))
|
||||
.unwrap()
|
||||
.position(),
|
||||
Point::new(-20., -10.)
|
||||
);
|
||||
}
|
||||
|
||||
/// Must run as the ONLY selected test in a dedicated, fresh test process.
|
||||
/// The ignored marker keeps this global-font mutation out of normal parallel
|
||||
/// desktop tests. See the evidence directory's exact executable runner.
|
||||
#[test]
|
||||
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
|
||||
fn actual_font_load_rejects_retained_label_and_old_session() {
|
||||
use graphics_text::cosmic_text::fontdb;
|
||||
use std::borrow::Cow;
|
||||
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let source = "Operation: Pressure";
|
||||
let label = cache
|
||||
.resolve(&session, source, 200., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
|
||||
assert!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.is_ok()
|
||||
);
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
|
||||
|
||||
let (version_before, version_after, faces_before, faces_after) = {
|
||||
let mut fonts = graphics_text::font_system().write().unwrap();
|
||||
let face = fonts
|
||||
.raw()
|
||||
.db()
|
||||
.faces()
|
||||
.find(|face| {
|
||||
face.families
|
||||
.iter()
|
||||
.any(|(family, _)| family == "Iced-Icons")
|
||||
})
|
||||
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
|
||||
.id;
|
||||
let bytes = fonts
|
||||
.raw()
|
||||
.db()
|
||||
.with_face_data(face, |data, _| data.to_vec())
|
||||
.expect("the built-in binary font has accessible bytes");
|
||||
assert!(!bytes.is_empty());
|
||||
// load_font increments Version even for invalid bytes. Independently
|
||||
// establish actual face admission so this cannot pass with invalid data.
|
||||
let mut probe = fontdb::Database::new();
|
||||
probe.load_font_data(bytes.clone());
|
||||
let admitted = probe
|
||||
.faces()
|
||||
.filter(|face| {
|
||||
face.families
|
||||
.iter()
|
||||
.any(|(family, _)| family == "Iced-Icons")
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
admitted > 0,
|
||||
"the extracted bytes parse as the expected real font"
|
||||
);
|
||||
let before = fonts.version();
|
||||
assert_eq!(before, session.version);
|
||||
let count = fonts.raw().db().faces().count();
|
||||
// Owned bytes intentionally bypass Borrowed-address deduplication.
|
||||
// This is an actual valid duplicate-face load, not a claim the chosen
|
||||
// face or rendered metrics change; the stale-epoch rule is the oracle.
|
||||
fonts.load_font(Cow::Owned(bytes));
|
||||
(
|
||||
before,
|
||||
fonts.version(),
|
||||
count,
|
||||
fonts.raw().db().faces().count(),
|
||||
)
|
||||
};
|
||||
assert_ne!(version_before, version_after);
|
||||
assert!(
|
||||
faces_after > faces_before,
|
||||
"actual font admission must accompany the version increment"
|
||||
);
|
||||
assert_eq!(
|
||||
label.displayed(&session, 1.).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.retained_source(&session, 1.).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.retains_any(&session, 1., 0..9).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, source, 200., 1., Style::default())
|
||||
.unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
cache.allocations, measured,
|
||||
"an invalid session must not remeasure and imply renderer repair"
|
||||
);
|
||||
println!(
|
||||
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
|
||||
);
|
||||
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
|
||||
// Process exit is the isolation boundary; no other test may run afterward.
|
||||
}
|
||||
@@ -0,0 +1,816 @@
|
||||
//! Pure complete Scenario geometry. No persistence, gesture or fallback policy.
|
||||
//! The only renderer is Scene::sequence_geometry; this module resolves its inputs.
|
||||
use super::*;
|
||||
use oa_core::{DiagramLayout, ScenarioLayout};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) mod presentation;
|
||||
|
||||
type Candidate = BTreeMap<Id, f32>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AnchorRole {
|
||||
MessageSend,
|
||||
MessageReceive,
|
||||
Observation,
|
||||
FrameStart,
|
||||
OperandStart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Anchor {
|
||||
pub(super) y: f32,
|
||||
role: AnchorRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MessageAnchors {
|
||||
send_y: f32,
|
||||
receive_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Diagnostic {
|
||||
SourceOrderCrossing { scope: Id, before: Id, after: Id },
|
||||
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GeometryIssue {
|
||||
UnknownAnchor(Id),
|
||||
LifecycleGeometryNotSupported { lifeline: Id, scope: Id },
|
||||
InvalidRepresentation(Id),
|
||||
InvalidCoordinate(Id),
|
||||
DuplicateCoordinate(Id),
|
||||
ExpandedAddressNotSupported(Id),
|
||||
CoincidentDistinctEndpoints(Id),
|
||||
TypedActivationOutsideScope { call: Id, scope: Id },
|
||||
TypedActivationOutsideLifetime { call: Id, receiver: Id },
|
||||
TypedActivationCrossingNotSupported { call: Id, event: Id },
|
||||
NonFinite(Id),
|
||||
CompoundMoveNotImplemented(Id),
|
||||
FixedFrameHeaderWouldMove { frame: Id },
|
||||
OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id },
|
||||
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
|
||||
LifetimeInversionNotSupported { create: Id, delete: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Plan {
|
||||
pub(super) anchors: BTreeMap<Id, Anchor>,
|
||||
messages: BTreeMap<Id, MessageAnchors>,
|
||||
pub(super) frames: BTreeMap<Id, Rectangle>,
|
||||
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
|
||||
pub(super) height: f32,
|
||||
pub(super) diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match p.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn role(body: &OccurrenceBody) -> AnchorRole {
|
||||
match body {
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
} => AnchorRole::MessageReceive,
|
||||
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
|
||||
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
|
||||
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
|
||||
_ => AnchorRole::Observation,
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(p: &Project, id: Id, owner: Id) -> bool {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
match current {
|
||||
Some(parent) if parent == owner => return true,
|
||||
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn depth(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut result = 0;
|
||||
while let Some(parent) = current {
|
||||
result += 1;
|
||||
if result > p.elements.len() {
|
||||
break;
|
||||
}
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let x = a.x.min(b.x);
|
||||
let y = a.y.min(b.y);
|
||||
Rectangle::new(
|
||||
Point::new(x, y),
|
||||
Size::new(
|
||||
(a.x + a.width).max(b.x + b.width) - x,
|
||||
(a.y + a.height).max(b.y + b.height) - y,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Actual named shapes/message paths supply footprint measurements; activation
|
||||
/// bars are intentionally excluded because current Scene derives them later.
|
||||
fn footprints(
|
||||
scene: &Scene,
|
||||
anchors: &BTreeMap<Id, Anchor>,
|
||||
seed: &BTreeMap<Id, Anchor>,
|
||||
) -> BTreeMap<Id, Rectangle> {
|
||||
let mut out = BTreeMap::new();
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(anchor) = anchors.get(&item.id) else {
|
||||
continue;
|
||||
};
|
||||
let mut rect = item.rect;
|
||||
rect.y += anchor.y - seed[&item.id].y;
|
||||
out.insert(item.id, rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
let Some(anchor) = anchors.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
let mut rect = line.label_bounds();
|
||||
rect.y += delta;
|
||||
for point in &line.points {
|
||||
rect = union(
|
||||
rect,
|
||||
Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO),
|
||||
);
|
||||
}
|
||||
out.entry(line.id)
|
||||
.and_modify(|old| *old = union(*old, rect))
|
||||
.or_insert(rect);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap<Id, Rectangle>) {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
let Some(parent) = current else {
|
||||
break;
|
||||
};
|
||||
covered
|
||||
.entry(parent)
|
||||
.and_modify(|old| *old = union(*old, rect))
|
||||
.or_insert(rect);
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
}
|
||||
|
||||
fn coverage(p: &Project, footprints: &BTreeMap<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
|
||||
let mut covered = BTreeMap::new();
|
||||
for (&id, &rect) in footprints {
|
||||
include_ancestors(p, id, rect, &mut covered);
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// Authored endpoint values are primary inputs, not deltas reconstructed from
|
||||
/// rounded rows. Lifecycle alignment owns x and marker attachment; this final
|
||||
/// y alignment precedes typed activation derivation and preserves send bits.
|
||||
pub(super) fn align_message_anchors(&self, lines: &mut [Line]) {
|
||||
for line in lines {
|
||||
let Some(message) = self.messages.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let previous_send = line.points.first().map_or(message.send_y, |point| point.y);
|
||||
line.label_at.y += message.send_y - previous_send;
|
||||
let last = line.points.len().saturating_sub(1);
|
||||
for (index, point) in line.points.iter_mut().enumerate() {
|
||||
point.y = if index == last || (last == 3 && index == 2) {
|
||||
message.receive_y
|
||||
} else {
|
||||
message.send_y
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
baseline: &Scene,
|
||||
desired: &Candidate,
|
||||
) -> Result<Self, GeometryIssue> {
|
||||
let mut seed: BTreeMap<_, _> = baseline
|
||||
.rows
|
||||
.iter()
|
||||
.map(|(id, (_, y))| {
|
||||
(
|
||||
*id,
|
||||
Anchor {
|
||||
y: *y,
|
||||
role: role(occurrence(p, *id).expect("Scene occurrence")),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Operand headers are real Scene boxes, but are not entries in rows.
|
||||
for item in &baseline.boxes {
|
||||
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
|
||||
seed.insert(
|
||||
item.id,
|
||||
Anchor {
|
||||
y: item.rect.y,
|
||||
role: AnchorRole::OperandStart,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut anchors = seed.clone();
|
||||
for (&id, &y) in desired {
|
||||
let anchor = anchors
|
||||
.get_mut(&id)
|
||||
.ok_or(GeometryIssue::UnknownAnchor(id))?;
|
||||
if !y.is_finite() {
|
||||
return Err(GeometryIssue::NonFinite(id));
|
||||
}
|
||||
if matches!(
|
||||
anchor.role,
|
||||
AnchorRole::FrameStart | AnchorRole::OperandStart
|
||||
) && y != anchor.y
|
||||
{
|
||||
return Err(GeometryIssue::CompoundMoveNotImplemented(id));
|
||||
}
|
||||
anchor.y = y;
|
||||
}
|
||||
let mut messages = BTreeMap::new();
|
||||
for line in &baseline.lines {
|
||||
if let (Some(anchor), Some(first), Some(last)) = (
|
||||
anchors.get(&line.id),
|
||||
line.points.first(),
|
||||
line.points.last(),
|
||||
) {
|
||||
let message = if desired.contains_key(&line.id) {
|
||||
let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange {
|
||||
sender: Some(sender), receiver, message_kind, ..
|
||||
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
|
||||
let lifecycle = matches!(
|
||||
occurrence(p, line.id),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
})
|
||||
);
|
||||
if self_message && lifecycle {
|
||||
MessageAnchors {
|
||||
send_y: anchor.y - 24.,
|
||||
receive_y: anchor.y,
|
||||
}
|
||||
} else {
|
||||
MessageAnchors {
|
||||
send_y: anchor.y,
|
||||
receive_y: if self_message {
|
||||
anchor.y + 24.
|
||||
} else {
|
||||
anchor.y
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessageAnchors {
|
||||
send_y: first.y,
|
||||
receive_y: last.y,
|
||||
}
|
||||
};
|
||||
messages.insert(line.id, message);
|
||||
}
|
||||
}
|
||||
// Track actual semantic lifecycle order per receiver and scope. In particular,
|
||||
// a later recreation must not be compared with an earlier deletion.
|
||||
for element in p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.id == owner || inside(p, e.id, owner))
|
||||
{
|
||||
let Some(steps) = element.steps() else {
|
||||
continue;
|
||||
};
|
||||
let mut last = BTreeMap::new();
|
||||
for id in steps {
|
||||
if let (
|
||||
Some(message),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
receiver: Some(lane),
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
}),
|
||||
) = (messages.get(id), occurrence(p, *id))
|
||||
&& let Some((previous, previous_y)) =
|
||||
last.insert(*lane, (*id, message.receive_y))
|
||||
&& previous_y > message.receive_y
|
||||
{
|
||||
return Err(GeometryIssue::LifetimeInversionNotSupported {
|
||||
create: previous,
|
||||
delete: *id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let old_footprints = footprints(baseline, &seed, &seed);
|
||||
let mut drawn = footprints(baseline, &anchors, &seed);
|
||||
let old_coverage = coverage(p, &old_footprints);
|
||||
let mut new_coverage = coverage(p, &drawn);
|
||||
let mut frames = BTreeMap::new();
|
||||
let mut containers: Vec<_> = seed
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. })))
|
||||
.collect();
|
||||
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
|
||||
for id in containers {
|
||||
let original = old_footprints[&id];
|
||||
let mut rect = original;
|
||||
if let Some(covered) = new_coverage.get(&id).copied() {
|
||||
// Preserve automatic extents and their existing descendant padding.
|
||||
// Upward envelope growth would move the painted frame header
|
||||
// while its fixed FrameStart/row/slot remained at the seed.
|
||||
// Reject that unsupported compound change, not mere overlap.
|
||||
if covered.y < rect.y {
|
||||
return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id });
|
||||
}
|
||||
let old_covered = old_coverage[&id];
|
||||
let padding =
|
||||
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
|
||||
// Preserve padding before the footprint itself reaches the old
|
||||
// frame edge: operand scopes already retain their own padding.
|
||||
rect.height = rect
|
||||
.height
|
||||
.max(covered.y + covered.height + padding - rect.y);
|
||||
}
|
||||
frames.insert(id, rect);
|
||||
drawn.insert(id, rect);
|
||||
include_ancestors(p, id, rect, &mut new_coverage);
|
||||
}
|
||||
let mut scopes = baseline.sequence_bounds.clone();
|
||||
for (&scope, bounds) in &mut scopes {
|
||||
if let (Some(old), Some(new)) = (
|
||||
old_coverage.get(&scope).copied(),
|
||||
new_coverage.get(&scope).copied(),
|
||||
) {
|
||||
let top_padding = (old.y - bounds.0).max(0.);
|
||||
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
|
||||
if new.y < bounds.0 {
|
||||
bounds.0 = new.y - top_padding;
|
||||
}
|
||||
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
|
||||
}
|
||||
}
|
||||
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
|
||||
let height = baseline.size.height.max(scopes[&owner].1 + tail);
|
||||
for (&operand, &(start, end)) in &scopes {
|
||||
if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) {
|
||||
continue;
|
||||
}
|
||||
let fragment = p.elements[&operand].parent.expect("operand fragment");
|
||||
let caller = p.elements[&fragment]
|
||||
.parent
|
||||
.expect("fragment caller sequence");
|
||||
let enclosing_frame = frames[&fragment];
|
||||
let (caller_start, caller_end) = if caller == owner {
|
||||
(85., height - 20.)
|
||||
} else {
|
||||
let (a, b) = scopes[&caller];
|
||||
(a, if a == b { a + 26. } else { b })
|
||||
};
|
||||
let enclosing_start = anchors[&fragment].y.max(caller_start);
|
||||
let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end);
|
||||
let effective_end = if start == end { start + 26. } else { end };
|
||||
// Actual lifecycle traversal cannot enter an operand that escapes
|
||||
// this enclosing interval. This guards bridge feasibility only;
|
||||
// it is not a generic event-spacing or sibling-ordering policy.
|
||||
if start < enclosing_start || effective_end > enclosing_end {
|
||||
return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval {
|
||||
operand,
|
||||
fragment,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (&id, message) in &messages {
|
||||
if matches!(
|
||||
occurrence(p, id),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
})
|
||||
) {
|
||||
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
|
||||
// Match Rendering::new's effective scope, not the insertion
|
||||
// scope: it overrides the root span to 85..height-20 and gives
|
||||
// empty non-root scopes the existing 26px lifetime allowance.
|
||||
let (start, end) = if scope == owner {
|
||||
(85., height - 20.)
|
||||
} else if let Some(&(start, end)) = scopes.get(&scope) {
|
||||
(start, if start == end { start + 26. } else { end })
|
||||
} else {
|
||||
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: id,
|
||||
scope,
|
||||
});
|
||||
};
|
||||
if message.receive_y < start || message.receive_y > end {
|
||||
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: id,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut diagnostics = Vec::new();
|
||||
for element in p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.id == owner || inside(p, e.id, owner))
|
||||
{
|
||||
if let Some(steps) = element.steps() {
|
||||
for pair in steps.windows(2) {
|
||||
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
|
||||
if a.y > b.y {
|
||||
diagnostics.push(Diagnostic::SourceOrderCrossing {
|
||||
scope: element.id,
|
||||
before: pair[0],
|
||||
after: pair[1],
|
||||
});
|
||||
}
|
||||
if let Some(frame) = frames.get(&pair[0])
|
||||
&& frame.y + frame.height > b.y
|
||||
{
|
||||
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame: pair[0],
|
||||
following: pair[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
anchors,
|
||||
messages,
|
||||
frames,
|
||||
scopes,
|
||||
height,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An appearance identity, never inferred from selection or from the owner alone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Representation {
|
||||
Derived { owner: Id },
|
||||
Saved { diagram: Id },
|
||||
}
|
||||
|
||||
/// Complete immutable geometry produced from one Project borrow. Scene is retained
|
||||
/// with its plan so a caller cannot accidentally render this plan against another
|
||||
/// Project or representation. Document/revision freshness belongs to that caller.
|
||||
pub(super) struct ResolvedScenarioPlan {
|
||||
pub(super) representation: Representation,
|
||||
pub(super) owner: Id,
|
||||
pub(super) lanes: BTreeMap<Id, f32>,
|
||||
pub(super) plan: Plan,
|
||||
pub(super) latent_messages: Vec<Id>,
|
||||
pub(super) bounds: Rectangle,
|
||||
pub(super) scene: Scene,
|
||||
}
|
||||
|
||||
pub(super) fn resolve(
|
||||
p: &Project,
|
||||
representation: Representation,
|
||||
selected: Id,
|
||||
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
|
||||
let (owner, layout) = match representation {
|
||||
Representation::Derived { owner } => (owner, None),
|
||||
Representation::Saved { diagram } => {
|
||||
let view = p
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
|
||||
let owner = view
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
|
||||
if p.elements
|
||||
.get(&owner)
|
||||
.is_none_or(|e| e.layer() != view.layer)
|
||||
{
|
||||
return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]);
|
||||
}
|
||||
let layout = view
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|DiagramLayout::Scenario(layout)| layout);
|
||||
(owner, layout)
|
||||
}
|
||||
};
|
||||
if p.elements
|
||||
.get(&owner)
|
||||
.is_none_or(|e| e.kind() != Kind::Scenario)
|
||||
{
|
||||
return Err(vec![GeometryIssue::InvalidRepresentation(owner)]);
|
||||
}
|
||||
let empty = ScenarioLayout::default();
|
||||
let layout = layout.unwrap_or(&empty);
|
||||
let mut lanes = BTreeMap::new();
|
||||
let mut issues = Vec::new();
|
||||
for lane in &layout.lanes {
|
||||
if p.elements
|
||||
.get(&lane.lifeline)
|
||||
.is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner))
|
||||
{
|
||||
issues.push(GeometryIssue::UnknownAnchor(lane.lifeline));
|
||||
}
|
||||
if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE {
|
||||
issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline));
|
||||
}
|
||||
if lanes.insert(lane.lifeline, lane.x).is_some() {
|
||||
issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline));
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
// The actual automatic traversal runs first, with authored lane centers already
|
||||
// applied. It supplies every untouched anchor, shape, insertion scope and label.
|
||||
let baseline = Scene::sequence_geometry(
|
||||
p,
|
||||
owner,
|
||||
selected,
|
||||
(!lanes.is_empty()).then_some(&lanes),
|
||||
None,
|
||||
);
|
||||
let mut desired = BTreeMap::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut latent_messages = Vec::new();
|
||||
let line_ids: std::collections::BTreeSet<_> =
|
||||
baseline.lines.iter().map(|line| line.id).collect();
|
||||
for message in &layout.messages {
|
||||
let id = message.address.occurrence;
|
||||
if !seen.insert(id) {
|
||||
issues.push(GeometryIssue::DuplicateCoordinate(id));
|
||||
}
|
||||
if !message.address.references.is_empty() {
|
||||
issues.push(GeometryIssue::ExpandedAddressNotSupported(id));
|
||||
}
|
||||
if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE {
|
||||
issues.push(GeometryIssue::InvalidCoordinate(id));
|
||||
}
|
||||
let Some(OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
..
|
||||
}) = occurrence(p, id)
|
||||
else {
|
||||
issues.push(GeometryIssue::UnknownAnchor(id));
|
||||
continue;
|
||||
};
|
||||
if !baseline.rows.contains_key(&id) {
|
||||
issues.push(GeometryIssue::UnknownAnchor(id));
|
||||
continue;
|
||||
}
|
||||
if !line_ids.contains(&id) {
|
||||
latent_messages.push(id);
|
||||
continue;
|
||||
}
|
||||
// Legacy lifecycle rows denote receipt, whereas the saved field denotes send.
|
||||
let row = if sender.is_some()
|
||||
&& sender == receiver
|
||||
&& matches!(message_kind, MessageKind::Create | MessageKind::Delete)
|
||||
{
|
||||
message.send_y + 24.
|
||||
} else {
|
||||
message.send_y
|
||||
};
|
||||
desired.insert(id, row);
|
||||
}
|
||||
let actual_lanes = &baseline.render_lanes;
|
||||
// A coincident pair is not a self message. Do not invent an arrow or lane order.
|
||||
for line in &baseline.lines {
|
||||
if let Some(OccurrenceBody::Exchange {
|
||||
sender: Some(sender),
|
||||
receiver,
|
||||
message_kind,
|
||||
..
|
||||
}) = occurrence(p, line.id)
|
||||
{
|
||||
let target = if matches!(message_kind, MessageKind::Timer { .. }) {
|
||||
Some(*sender)
|
||||
} else {
|
||||
*receiver
|
||||
};
|
||||
if target.is_some_and(|target| target != *sender)
|
||||
&& target
|
||||
.is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target))
|
||||
{
|
||||
issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?;
|
||||
// Keep the schema's exact send value; send+24 is performed once. In particular,
|
||||
// a self Create/Delete receive row must never round-trip back into saved send.
|
||||
for message in &layout.messages {
|
||||
let id = message.address.occurrence;
|
||||
if let Some(anchors) = plan.messages.get_mut(&id) {
|
||||
let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange {
|
||||
sender: Some(sender), receiver, message_kind, ..
|
||||
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
|
||||
*anchors = MessageAnchors {
|
||||
send_y: message.send_y,
|
||||
receive_y: if self_message {
|
||||
message.send_y + 24.
|
||||
} else {
|
||||
message.send_y
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// Typed bars require a positive receiver interval. This is a narrow renderer
|
||||
// limitation, not a global ban on reordered/negative asynchronous anchors.
|
||||
for (&id, message) in &plan.messages {
|
||||
match occurrence(p, id) {
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
}) => {
|
||||
let scope = baseline.rows[&id].0;
|
||||
let (start, end) = if scope == owner {
|
||||
(85., plan.height - 20.)
|
||||
} else {
|
||||
plan.scopes[&scope]
|
||||
};
|
||||
if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end)
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope });
|
||||
}
|
||||
}
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
reply_to: Some(call),
|
||||
..
|
||||
}) => {
|
||||
if let Some(start) = plan.messages.get(call)
|
||||
&& (desired.contains_key(&id) || desired.contains_key(call))
|
||||
&& message.send_y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: *call,
|
||||
event: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
for segment in &baseline.activations {
|
||||
if baseline
|
||||
.rows
|
||||
.get(&segment.call)
|
||||
.is_none_or(|(scope, _)| *scope != segment.scope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(start) = plan.messages.get(&segment.call) else {
|
||||
continue;
|
||||
};
|
||||
let event = match segment.finish {
|
||||
scenario_activation::Finish::Reply(event)
|
||||
| scenario_activation::Finish::Terminated(event) => Some(event),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event
|
||||
&& (desired.contains_key(&event) || desired.contains_key(&segment.call))
|
||||
&& plan.anchors[&event].y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: segment.call,
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
let scene = Scene::sequence_geometry(
|
||||
p,
|
||||
owner,
|
||||
selected,
|
||||
(!lanes.is_empty()).then_some(&lanes),
|
||||
Some(&plan),
|
||||
);
|
||||
// Compare the actual projector, whose cursor rules include sibling scopes and
|
||||
// lifecycle/fragment events. Retain existing incomplete-model uncertainty;
|
||||
// reject only newly missing geometry introduced by this presentation.
|
||||
let previous_missing = baseline.lifecycle.missing_geometry();
|
||||
for (lifeline, scope) in scene
|
||||
.lifecycle
|
||||
.missing_geometry()
|
||||
.difference(&previous_missing)
|
||||
{
|
||||
issues.push(GeometryIssue::LifecycleGeometryNotSupported {
|
||||
lifeline: *lifeline,
|
||||
scope: *scope,
|
||||
});
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
for segment in &baseline.activations {
|
||||
let Some(seed) = baseline
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == segment.call)
|
||||
.and_then(|line| line.points.last())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope {
|
||||
continue;
|
||||
}
|
||||
let receive = plan.messages[&segment.call].receive_y;
|
||||
if !desired.is_empty()
|
||||
&& !scene.activations.iter().any(|current| {
|
||||
current.call == segment.call
|
||||
&& current.scope == segment.scope
|
||||
&& current.rect.y == receive
|
||||
})
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationOutsideLifetime {
|
||||
call: segment.call,
|
||||
receiver: segment.receiver,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
let bounds = structural_bounds(&scene);
|
||||
let all_lanes = scene.render_lanes.clone();
|
||||
Ok(ResolvedScenarioPlan {
|
||||
representation,
|
||||
owner,
|
||||
lanes: all_lanes,
|
||||
plan,
|
||||
latent_messages,
|
||||
bounds,
|
||||
scene,
|
||||
})
|
||||
}
|
||||
|
||||
fn structural_bounds(scene: &Scene) -> Rectangle {
|
||||
let mut bounds = Rectangle::new(scene.layout_origin, scene.size);
|
||||
for item in &scene.boxes {
|
||||
bounds = union(bounds, item.rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
if !line.label.is_empty() {
|
||||
bounds = union(bounds, line.label_bounds());
|
||||
}
|
||||
for point in &line.points {
|
||||
// Arrow wings extend beyond the centerline in either direction.
|
||||
bounds = union(
|
||||
bounds,
|
||||
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for segment in &scene.activations {
|
||||
bounds = union(bounds, segment.rect);
|
||||
}
|
||||
for point in &scene.open_calls {
|
||||
bounds = union(
|
||||
bounds,
|
||||
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
|
||||
);
|
||||
}
|
||||
if let Some(lifetime) = scene.lifecycle.bounds() {
|
||||
bounds = union(bounds, lifetime);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Display policy for one requested Scenario representation, without authoring.
|
||||
//! The resolver remains the sole renderer. Freshness and editor admission belong
|
||||
//! to the caller; a capability here never bypasses those guards.
|
||||
use super::{Diagnostic, GeometryIssue, Representation, ResolvedScenarioPlan, resolve};
|
||||
use crate::behavior::Scene;
|
||||
use iced::Rectangle;
|
||||
use oa_core::{DiagramLayout, Id, Kind, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PresentationStatus {
|
||||
Ready,
|
||||
Fallback {
|
||||
requested_issues: Vec<GeometryIssue>,
|
||||
},
|
||||
Unavailable {
|
||||
requested_issues: Vec<GeometryIssue>,
|
||||
automatic_issues: Vec<GeometryIssue>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PresentationCapabilities {
|
||||
/// Actual geometry exists for inspection, Fit and geometry-based navigation.
|
||||
pub(crate) geometry_navigation: bool,
|
||||
/// Still subject to document, gesture, editor and view-visit admission.
|
||||
pub(crate) coordinate_input: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ScenarioPresentation {
|
||||
requested: Representation,
|
||||
owner: Option<Id>,
|
||||
status: PresentationStatus,
|
||||
geometry: Option<ResolvedScenarioPlan>,
|
||||
// Saved metadata must survive a complete automatic fallback.
|
||||
latent_messages: Vec<Id>,
|
||||
}
|
||||
|
||||
impl ScenarioPresentation {
|
||||
pub(crate) fn requested(&self) -> Representation {
|
||||
self.requested
|
||||
}
|
||||
pub(crate) fn owner(&self) -> Option<Id> {
|
||||
self.owner
|
||||
}
|
||||
pub(crate) fn status(&self) -> &PresentationStatus {
|
||||
&self.status
|
||||
}
|
||||
pub(crate) fn geometry_representation(&self) -> Option<Representation> {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.map(|geometry| geometry.representation)
|
||||
}
|
||||
pub(crate) fn bounds(&self) -> Option<Rectangle> {
|
||||
self.geometry.as_ref().map(|geometry| geometry.bounds)
|
||||
}
|
||||
/// Diagnostics describe the actual displayed geometry. Requested hard failures
|
||||
/// are retained separately in status(), never presented as automatic warnings.
|
||||
pub(crate) fn diagnostics(&self) -> &[Diagnostic] {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.map_or(&[], |geometry| &geometry.plan.diagnostics)
|
||||
}
|
||||
pub(crate) fn latent_messages(&self) -> &[Id] {
|
||||
&self.latent_messages
|
||||
}
|
||||
pub(crate) fn capabilities(&self) -> PresentationCapabilities {
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: self.geometry.is_some(),
|
||||
coordinate_input: matches!(self.status, PresentationStatus::Ready),
|
||||
}
|
||||
}
|
||||
pub(in crate::behavior) fn scene(&self) -> Option<&Scene> {
|
||||
self.geometry.as_ref().map(|geometry| &geometry.scene)
|
||||
}
|
||||
pub(in crate::behavior) fn into_scene(self) -> Option<Scene> {
|
||||
self.geometry.map(|geometry| geometry.scene)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a structurally accepted Project for passive display. This is not a
|
||||
/// second model validator: identity and resolver-reported structural failures
|
||||
/// cannot be converted into a more permissive automatic representation.
|
||||
pub(crate) fn present(
|
||||
project: &Project,
|
||||
requested: Representation,
|
||||
selected: Id,
|
||||
) -> ScenarioPresentation {
|
||||
let owner = checked_owner(project, requested);
|
||||
let Some(owner_id) = owner else {
|
||||
let id = match requested {
|
||||
Representation::Derived { owner } => owner,
|
||||
Representation::Saved { diagram } => diagram,
|
||||
};
|
||||
return ScenarioPresentation {
|
||||
requested,
|
||||
owner: None,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues: vec![GeometryIssue::InvalidRepresentation(id)],
|
||||
automatic_issues: vec![],
|
||||
},
|
||||
};
|
||||
};
|
||||
match resolve(project, requested, selected) {
|
||||
Ok(geometry) => ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
status: PresentationStatus::Ready,
|
||||
latent_messages: geometry.latent_messages.clone(),
|
||||
geometry: Some(geometry),
|
||||
},
|
||||
Err(requested_issues) => {
|
||||
if matches!(requested, Representation::Derived { .. })
|
||||
|| requested_issues.iter().any(structural_failure)
|
||||
{
|
||||
return ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues,
|
||||
automatic_issues: vec![],
|
||||
},
|
||||
};
|
||||
}
|
||||
match resolve(
|
||||
project,
|
||||
Representation::Derived { owner: owner_id },
|
||||
selected,
|
||||
) {
|
||||
Ok(geometry) => {
|
||||
let latent_messages =
|
||||
saved_latent_messages(project, requested, &geometry.scene);
|
||||
ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
status: PresentationStatus::Fallback { requested_issues },
|
||||
geometry: Some(geometry),
|
||||
latent_messages,
|
||||
}
|
||||
}
|
||||
Err(automatic_issues) => ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues,
|
||||
automatic_issues,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option<Id> {
|
||||
let owner = match requested {
|
||||
Representation::Derived { owner } => owner,
|
||||
Representation::Saved { diagram } => {
|
||||
let view = project.diagrams.get(&diagram)?;
|
||||
let owner = view.projection.scenario()?;
|
||||
if view.id != diagram || project.elements.get(&owner)?.layer() != view.layer {
|
||||
return None;
|
||||
}
|
||||
owner
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario)
|
||||
.then_some(owner)
|
||||
}
|
||||
|
||||
fn structural_failure(issue: &GeometryIssue) -> bool {
|
||||
// These violate the existing identity/address/finite-coordinate admission
|
||||
// contract. Geometry support failures below do not invalidate stored intent.
|
||||
match issue {
|
||||
GeometryIssue::InvalidRepresentation(_)
|
||||
| GeometryIssue::UnknownAnchor(_)
|
||||
| GeometryIssue::InvalidCoordinate(_)
|
||||
| GeometryIssue::DuplicateCoordinate(_)
|
||||
| GeometryIssue::ExpandedAddressNotSupported(_)
|
||||
| GeometryIssue::NonFinite(_) => true,
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::CoincidentDistinctEndpoints(_)
|
||||
| GeometryIssue::TypedActivationOutsideScope { .. }
|
||||
| GeometryIssue::TypedActivationOutsideLifetime { .. }
|
||||
| GeometryIssue::TypedActivationCrossingNotSupported { .. }
|
||||
| GeometryIssue::CompoundMoveNotImplemented(_)
|
||||
| GeometryIssue::FixedFrameHeaderWouldMove { .. }
|
||||
| GeometryIssue::OperandScopeOutsideEnclosingInterval { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_latent_messages(project: &Project, requested: Representation, scene: &Scene) -> Vec<Id> {
|
||||
let Representation::Saved { diagram } = requested else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(DiagramLayout::Scenario(layout)) = project.diagrams[&diagram].layout.as_ref() else {
|
||||
return vec![];
|
||||
};
|
||||
let lines: BTreeSet<_> = scene.lines.iter().map(|line| line.id).collect();
|
||||
layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
let id = message.address.occurrence;
|
||||
(message.address.references.is_empty()
|
||||
&& scene.rows.contains_key(&id)
|
||||
&& !lines.contains(&id)
|
||||
&& matches!(
|
||||
project
|
||||
.elements
|
||||
.get(&id)
|
||||
.and_then(|element| element.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{
|
||||
Command, Diagram, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition, ScenarioLayout,
|
||||
ScenarioMessagePosition, language, validation,
|
||||
};
|
||||
|
||||
fn fixture() -> (Project, Id) {
|
||||
let project = language::parse(FIXTURE).unwrap();
|
||||
assert_admitted(&project);
|
||||
let owner = named(&project, "conversation");
|
||||
(project, owner)
|
||||
}
|
||||
fn named(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn save(project: &mut Project, owner: Id, layout: Option<ScenarioLayout>) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{}", project.diagrams.len()),
|
||||
name: "Saved conversation".into(),
|
||||
description: String::new(),
|
||||
layer: project.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: layout.map(DiagramLayout::Scenario),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn layout(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout {
|
||||
ScenarioLayout {
|
||||
lanes: lanes
|
||||
.iter()
|
||||
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
|
||||
.collect(),
|
||||
messages: messages
|
||||
.iter()
|
||||
.map(|&(occurrence, send_y)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn same_geometry(a: &Scene, b: &Scene) {
|
||||
assert!(
|
||||
!a.activations.is_empty(),
|
||||
"fallback must include real typed executions"
|
||||
);
|
||||
assert_eq!(a.activations, b.activations);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.render_lanes, b.render_lanes);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
assert_eq!((a.size, a.layout_origin), (b.size, b.layout_origin));
|
||||
let boxes = |scene: &Scene| {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|item| {
|
||||
(
|
||||
item.id,
|
||||
item.rect,
|
||||
item.title.clone(),
|
||||
item.detail.clone(),
|
||||
item.marker,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let lines = |scene: &Scene| {
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
(
|
||||
line.id,
|
||||
line.points.clone(),
|
||||
line.label.clone(),
|
||||
line.label_at,
|
||||
line.dashed,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let slots = |scene: &Scene| {
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
(
|
||||
slot.slot.parent,
|
||||
slot.slot.before,
|
||||
slot.y,
|
||||
slot.label.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(boxes(a), boxes(b));
|
||||
assert_eq!(lines(a), lines(b));
|
||||
assert_eq!(slots(a), slots(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_saved_layouts_remain_saved_ready_representations() {
|
||||
let (mut project, owner) = fixture();
|
||||
let absent = save(&mut project, owner, None);
|
||||
let empty = save(&mut project, owner, Some(ScenarioLayout::default()));
|
||||
assert_admitted(&project);
|
||||
let automatic = present(&project, Representation::Derived { owner }, owner);
|
||||
assert_eq!(automatic.status(), &PresentationStatus::Ready);
|
||||
for diagram in [absent, empty] {
|
||||
let requested = Representation::Saved { diagram };
|
||||
let displayed = present(&project, requested, owner);
|
||||
assert_eq!(displayed.requested(), requested);
|
||||
assert_eq!(displayed.geometry_representation(), Some(requested));
|
||||
assert_eq!(displayed.owner(), Some(owner));
|
||||
assert_eq!(displayed.status(), &PresentationStatus::Ready);
|
||||
assert!(displayed.capabilities().coordinate_input);
|
||||
assert_eq!(displayed.bounds(), automatic.bounds());
|
||||
same_geometry(displayed.scene().unwrap(), automatic.scene().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_crossing_diagnostic_keeps_authored_geometry_and_latent_identity() {
|
||||
let (mut project, owner) = fixture();
|
||||
let first = named(&project, "first");
|
||||
let second = named(&project, "second");
|
||||
let left = named(&project, "left");
|
||||
let unfinished = named(&project, "unfinished");
|
||||
let diagram = save(
|
||||
&mut project,
|
||||
owner,
|
||||
Some(layout(
|
||||
&[(first, 1000.), (unfinished, 2000.)],
|
||||
&[(left, 900.)],
|
||||
)),
|
||||
);
|
||||
assert_admitted(&project);
|
||||
let shown = present(&project, Representation::Saved { diagram }, first);
|
||||
assert_eq!(shown.status(), &PresentationStatus::Ready);
|
||||
assert_eq!(shown.scene().unwrap().rows[&first].1, 1000.);
|
||||
assert_eq!(shown.scene().unwrap().rows[&second].1, 240.);
|
||||
assert_eq!(shown.scene().unwrap().render_lanes[&left], 900.);
|
||||
assert!(
|
||||
shown
|
||||
.diagnostics()
|
||||
.contains(&Diagnostic::SourceOrderCrossing {
|
||||
scope: owner,
|
||||
before: first,
|
||||
after: second
|
||||
})
|
||||
);
|
||||
assert_eq!(shown.latent_messages(), &[unfinished]);
|
||||
assert_eq!(shown.scene().unwrap().rows[&unfinished].1, 516.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_failure_uses_complete_automatic_geometry_but_retains_each_saved_request_and_latent_notice()
|
||||
{
|
||||
let (mut project, owner) = fixture();
|
||||
let call = named(&project, "call");
|
||||
let reply = named(&project, "reply");
|
||||
let unfinished = named(&project, "unfinished");
|
||||
let left = named(&project, "left");
|
||||
let right = named(&project, "right");
|
||||
let authored = layout(
|
||||
&[(reply, 300.), (unfinished, 2000.)],
|
||||
&[(left, 900.), (right, -500.)],
|
||||
);
|
||||
let a = save(&mut project, owner, Some(authored.clone()));
|
||||
let b = save(&mut project, owner, Some(authored));
|
||||
assert_admitted(&project);
|
||||
let before = project.clone();
|
||||
let automatic = present(&project, Representation::Derived { owner }, call);
|
||||
let control = automatic.scene().unwrap();
|
||||
assert_eq!(control.render_lanes[&left], 160.);
|
||||
assert_eq!(control.render_lanes[&right], 400.);
|
||||
assert_eq!(control.rows[&reply].1, 424.);
|
||||
assert_eq!(control.activations.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
control.activations[0].rect.y,
|
||||
control.activations[0].rect.height
|
||||
),
|
||||
(356., 68.)
|
||||
);
|
||||
for diagram in [a, b] {
|
||||
let request = Representation::Saved { diagram };
|
||||
let shown = present(&project, request, call);
|
||||
assert_eq!(shown.requested(), request);
|
||||
assert_eq!(
|
||||
shown.geometry_representation(),
|
||||
Some(Representation::Derived { owner })
|
||||
);
|
||||
assert_eq!(
|
||||
shown.status(),
|
||||
&PresentationStatus::Fallback {
|
||||
requested_issues: vec![GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call,
|
||||
event: reply
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
shown.capabilities(),
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: true,
|
||||
coordinate_input: false
|
||||
}
|
||||
);
|
||||
assert_eq!(shown.latent_messages(), &[unfinished]);
|
||||
assert!(shown.diagnostics().is_empty());
|
||||
assert_eq!(shown.bounds(), automatic.bounds());
|
||||
same_geometry(shown.scene().unwrap(), control);
|
||||
}
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_incompatible_identity_and_structural_coordinate_errors_are_unavailable() {
|
||||
let (mut project, owner) = fixture();
|
||||
let left = named(&project, "left");
|
||||
let diagram = save(&mut project, owner, None);
|
||||
let missing = Id::new_v4();
|
||||
for request in [
|
||||
Representation::Saved { diagram: missing },
|
||||
Representation::Derived { owner: left },
|
||||
] {
|
||||
let shown = present(&project, request, owner);
|
||||
assert_eq!(shown.requested(), request);
|
||||
assert_eq!(shown.owner(), None);
|
||||
assert!(
|
||||
matches!(shown.status(), PresentationStatus::Unavailable { automatic_issues, .. } if automatic_issues.is_empty())
|
||||
);
|
||||
assert!(shown.scene().is_none() && shown.bounds().is_none());
|
||||
assert_eq!(
|
||||
shown.capabilities(),
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: false,
|
||||
coordinate_input: false
|
||||
}
|
||||
);
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::Scenario { scenario: missing };
|
||||
assert!(matches!(
|
||||
present(&project, Representation::Saved { diagram }, owner).status(),
|
||||
PresentationStatus::Unavailable { .. }
|
||||
));
|
||||
// Deliberately invalid-model control, not a warning-admitted geometry failure.
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::Scenario { scenario: owner };
|
||||
project.diagrams.get_mut(&diagram).unwrap().layout = Some(DiagramLayout::Scenario(layout(
|
||||
&[],
|
||||
&[(left, oa_core::MAX_COORDINATE + 1.)],
|
||||
)));
|
||||
assert!(
|
||||
validation::validate(&project)
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error)
|
||||
);
|
||||
let before = project.clone();
|
||||
let shown = present(&project, Representation::Saved { diagram }, owner);
|
||||
assert!(
|
||||
matches!(shown.status(), PresentationStatus::Unavailable { requested_issues, automatic_issues }
|
||||
if requested_issues == &[GeometryIssue::InvalidCoordinate(left)] && automatic_issues.is_empty())
|
||||
);
|
||||
assert!(shown.scene().is_none());
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_fallback_preserves_exact_document_source_and_one_step_undo_redo() {
|
||||
let (mut project, owner) = fixture();
|
||||
let first = named(&project, "first");
|
||||
let reply = named(&project, "reply");
|
||||
let view = save(&mut project, owner, Some(layout(&[(reply, 300.)], &[])));
|
||||
assert_admitted(&project);
|
||||
let mut document = Document::from_project(project, Default::default()).unwrap();
|
||||
let initial_project = document.project().clone();
|
||||
let initial_source = document.bundle().clone();
|
||||
document
|
||||
.execute(Command::Rename(first, "An intentional prior edit".into()))
|
||||
.unwrap();
|
||||
let accepted_project = document.project().clone();
|
||||
let accepted_source = document.bundle().clone();
|
||||
let revision = document.revision();
|
||||
{
|
||||
let shown = present(
|
||||
document.project(),
|
||||
Representation::Saved { diagram: view },
|
||||
first,
|
||||
);
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert!(shown.scene().is_some());
|
||||
}
|
||||
assert_eq!(document.project(), &accepted_project);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &initial_project);
|
||||
assert_eq!(document.bundle(), &initial_source);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &accepted_project);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
}
|
||||
|
||||
const FIXTURE: &str = r#"arcadia 7;
|
||||
project display_probe "Scenario display probe" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction exchange "Repeated exchange" id "70000000-0000-4000-8000-000000000004" { source @source; target @target; }
|
||||
oa_scenario conversation "Conversation" id "70000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
|
||||
oa_lifeline left "Left" id "70000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "70000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
|
||||
oa_occurrence first "First" id "70000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence second "Second" id "70000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence call "Call" id "70000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
|
||||
oa_occurrence reply "Reply" id "70000000-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 "70000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ struct EventMarker {
|
||||
capsule: Rectangle,
|
||||
name: String,
|
||||
sender_x: Option<f32>,
|
||||
self_message: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
@@ -89,7 +90,7 @@ impl Rendering {
|
||||
continue;
|
||||
};
|
||||
for event in &lane.markers {
|
||||
let sender_x = project
|
||||
let sender_id = project
|
||||
.elements
|
||||
.get(&event.occurrence)
|
||||
.and_then(|e| e.shared())
|
||||
@@ -101,7 +102,7 @@ impl Rendering {
|
||||
..
|
||||
},
|
||||
..
|
||||
} => lanes.get(sender).copied(),
|
||||
} => Some(*sender),
|
||||
_ => None,
|
||||
});
|
||||
markers.push(EventMarker {
|
||||
@@ -113,7 +114,8 @@ impl Rendering {
|
||||
Size::new(140., 28.),
|
||||
),
|
||||
name: project.named(lane.lifeline).to_owned(),
|
||||
sender_x,
|
||||
sender_x: sender_id.and_then(|id| lanes.get(&id).copied()),
|
||||
self_message: sender_id == Some(lane.lifeline),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -138,7 +140,7 @@ impl Rendering {
|
||||
};
|
||||
let x = marker.center.x;
|
||||
let y = marker.center.y;
|
||||
let self_message = sender == x;
|
||||
let self_message = marker.self_message;
|
||||
let end_x = match marker.event.kind {
|
||||
MarkerKind::Birth if sender < x => marker.capsule.x,
|
||||
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
|
||||
@@ -166,6 +168,48 @@ impl Rendering {
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry loss is distinct from warning-admitted semantic uncertainty.
|
||||
/// The saved bridge compares these exact projector diagnostics with baseline.
|
||||
pub(in crate::behavior) fn missing_geometry(&self) -> std::collections::BTreeSet<(Id, Id)> {
|
||||
self.lifetimes
|
||||
.iter()
|
||||
.flat_map(|lane| {
|
||||
lane.uncertainties
|
||||
.iter()
|
||||
.filter(|issue| issue.reason == super::UncertaintyReason::MissingGeometry)
|
||||
.map(|issue| (lane.lifeline, issue.scope))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Complete structural lifetime bounds, including the birth capsule and death mark.
|
||||
pub(in crate::behavior) fn bounds(&self) -> Option<Rectangle> {
|
||||
let mut bounds = self.span.and_then(|span| {
|
||||
self.lanes
|
||||
.values()
|
||||
.map(|x| {
|
||||
Rectangle::new(
|
||||
Point::new(*x, span.start),
|
||||
Size::new(0., span.end - span.start),
|
||||
)
|
||||
})
|
||||
.reduce(crate::behavior::scenario_layout::union)
|
||||
});
|
||||
for marker in &self.markers {
|
||||
let rect = match marker.event.kind {
|
||||
MarkerKind::Birth => marker.capsule,
|
||||
MarkerKind::Death => Rectangle::new(
|
||||
Point::new(marker.center.x - 10., marker.center.y - 10.),
|
||||
Size::new(20., 20.),
|
||||
),
|
||||
};
|
||||
bounds = Some(bounds.map_or(rect, |old| {
|
||||
crate::behavior::scenario_layout::union(old, rect)
|
||||
}));
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn alive_spans(
|
||||
&self,
|
||||
lifeline: Id,
|
||||
|
||||
@@ -443,8 +443,8 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
|
||||
let birth = f.message(f.owner, MessageKind::Create);
|
||||
let c1 = f.message(f.owner, MessageKind::Call);
|
||||
let c2 = f.message(f.owner, MessageKind::Call);
|
||||
f.reply(f.owner, c2);
|
||||
f.reply(f.owner, c1);
|
||||
let r2 = f.reply(f.owner, c2);
|
||||
let r1 = f.reply(f.owner, c1);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
let document = f.document();
|
||||
let before = document.snapshot();
|
||||
@@ -454,6 +454,14 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
|
||||
});
|
||||
assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.);
|
||||
assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.);
|
||||
for scene in [&baseline, &moved] {
|
||||
assert_eq!(scene.activations.len(), 2);
|
||||
for (call, reply) in [(c1, r1), (c2, r2)] {
|
||||
assert!(scene.activations.iter().any(|segment| {
|
||||
segment.call == call && segment.finish == Finish::Reply(reply)
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (a, b) in baseline.activations.iter().zip(&moved.activations) {
|
||||
assert_eq!(
|
||||
(a.call, a.receiver, a.scope, a.column, a.finish),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user