Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1006b496ae | ||
|
|
a2a326f183 | ||
|
|
e34ba27e26 | ||
|
|
f82e29d6b8 | ||
|
|
c4897485b7 | ||
|
|
015273ac8a | ||
|
|
5e04800be1 | ||
|
|
f3dd8356bf | ||
|
|
846cae81e7 | ||
|
|
2a0579daaf | ||
|
|
ffa7b16003 | ||
|
|
a9b9c81eab | ||
|
|
0b61771d07 | ||
|
|
6f4dfc67b3 | ||
|
|
5960563b23 | ||
|
|
df4256cb2b | ||
|
|
1d56386708 | ||
|
|
c87a0c2bd9 | ||
|
|
cc3d6a4875 | ||
|
|
6a6cc83bc8 | ||
|
|
9f88938632 | ||
|
|
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+600
-33
@@ -21,6 +21,12 @@ mod multiselect_tests;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod scenario_details_tests;
|
||||
mod scenario_diagram;
|
||||
mod scenario_spacing;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
@@ -32,6 +38,7 @@ pub(crate) mod text_workspace;
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
mod view_copy_tests;
|
||||
pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "This action uses architecture diagram tools and is unavailable for a saved Scenario. Open the view in Behavior; saved positions are preserved.";
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
behavior,
|
||||
@@ -435,6 +442,7 @@ pub enum Message {
|
||||
Pan(iced::Vector),
|
||||
Fit,
|
||||
SourceEdit(text_editor::Action),
|
||||
SourceIndent(crate::source_navigation::SymbolContext, bool),
|
||||
ApplySource,
|
||||
RevertSource,
|
||||
SourceUndo,
|
||||
@@ -553,6 +561,7 @@ pub struct App {
|
||||
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
|
||||
source_diagram_job: Option<Id>,
|
||||
diagram_lookup_in_flight: Option<Id>,
|
||||
pending_scenario_reveal_visit: Option<Id>,
|
||||
pending_diagram_reveal: Option<(
|
||||
crate::diagram_usage::Context,
|
||||
crate::diagram_usage::Usage,
|
||||
@@ -601,7 +610,7 @@ impl App {
|
||||
let active_diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
let draft = Draft::from_project(&project, selected);
|
||||
@@ -679,6 +688,7 @@ impl App {
|
||||
source_diagram_job: None,
|
||||
diagram_lookup_in_flight: None,
|
||||
pending_diagram_reveal: None,
|
||||
pending_scenario_reveal_visit: None,
|
||||
model_issues,
|
||||
migration: None,
|
||||
source_version: 0,
|
||||
@@ -693,6 +703,7 @@ impl App {
|
||||
workflow: None,
|
||||
};
|
||||
app.route_state_diagram_workspace();
|
||||
app.reconcile_scenario_workspace();
|
||||
app.last_workspace_page = app.page;
|
||||
app
|
||||
}
|
||||
@@ -828,6 +839,17 @@ impl App {
|
||||
self.sync_with_alias_editor(None);
|
||||
}
|
||||
fn sync_with_alias_editor(&mut self, hint: Option<source_rename::EditorRefresh<'_>>) {
|
||||
// A saved view may be retargeted across layers by one source transaction.
|
||||
// Follow that explicit identity before generic active-view normalization.
|
||||
if let Some(rep @ behavior::scenario_layout::Representation::Saved { diagram }) =
|
||||
self.behavior.scenario.representation()
|
||||
&& self.active_diagram == Some(diagram)
|
||||
&& let Some(owner) =
|
||||
behavior::scenario_layout::presentation::checked_owner(self.project(), rep)
|
||||
{
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
}
|
||||
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
|
||||
self.model_issues = validate(self.project());
|
||||
self.normalize_creation();
|
||||
@@ -852,7 +874,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == self.layer)
|
||||
.filter(|d| d.layer == self.layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
}
|
||||
@@ -932,6 +954,26 @@ impl App {
|
||||
.map_err(|e| e.to_string())
|
||||
}) {
|
||||
Ok(id) => {
|
||||
if self.behavior.scenario.representation()
|
||||
== Some(behavior::scenario_layout::Representation::Saved { diagram: original })
|
||||
&& id != original
|
||||
{
|
||||
let representation =
|
||||
behavior::scenario_layout::Representation::Saved { diagram: id };
|
||||
if let Some(owner) = behavior::scenario_layout::presentation::checked_owner(
|
||||
self.project(),
|
||||
representation,
|
||||
) {
|
||||
self.behavior.scenario.enter_representation(
|
||||
self.canvas_document,
|
||||
owner,
|
||||
representation,
|
||||
);
|
||||
if self.page != Page::Behavior {
|
||||
self.behavior.scenario.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.active_diagram == Some(original) {
|
||||
self.active_diagram = Some(id);
|
||||
}
|
||||
@@ -1106,7 +1148,7 @@ impl App {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.layer == layer)
|
||||
.is_some_and(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
})
|
||||
.or(retained)
|
||||
.or_else(|| self.diagram_for(layer, target));
|
||||
@@ -1141,6 +1183,17 @@ impl App {
|
||||
&& let Some(owner) = crate::behavior::selected_behavior(self.project(), id)
|
||||
{
|
||||
self.behavior.active_owner = Some(owner);
|
||||
if self.behavior.scenario.representation().is_some_and(|rep| {
|
||||
behavior::scenario_layout::presentation::checked_owner(self.project(), rep)
|
||||
!= Some(owner)
|
||||
}) && self.project().elements[&owner].kind() == Kind::Scenario
|
||||
{
|
||||
self.enter_scenario_representation(
|
||||
behavior::scenario_layout::Representation::Derived { owner },
|
||||
);
|
||||
} else if self.project().elements[&owner].kind() != Kind::Scenario {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
}
|
||||
if self.reset_state_view_after_navigation(previous_state_view)
|
||||
&& self.page == Page::Behavior
|
||||
@@ -1353,6 +1406,9 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Spacing(event)) = &message {
|
||||
return self.scenario_spacing(*event);
|
||||
}
|
||||
// Reject stale rename intents before camera/reveal reconciliation or
|
||||
// source interaction can disturb a newer navigation destination.
|
||||
if let Message::SourceRename(event) = &message
|
||||
@@ -1365,6 +1421,53 @@ impl App {
|
||||
if let Message::SourceRename(event @ source_rename::Event::PreviewScrolled(..)) = message {
|
||||
return self.source_rename_event(event);
|
||||
}
|
||||
// Prior view visits are inert before they can retire a newer reveal,
|
||||
// camera lifetime or focus operation. Passive events may relax epoch,
|
||||
// but never the destination identity.
|
||||
if let Message::ScenarioCanvas(event) = &message {
|
||||
let captured = event.context();
|
||||
if self.scenario_context().is_none_or(|current| {
|
||||
current.document != captured.document
|
||||
|| current.scenario != captured.scenario
|
||||
|| current.representation != captured.representation
|
||||
|| current.visit != captured.visit
|
||||
|| (!matches!(event, behavior::scenario::CanvasEvent::Restored(..))
|
||||
&& current.revision != captured.revision)
|
||||
|| (!matches!(
|
||||
event,
|
||||
behavior::scenario::CanvasEvent::FormShown(..)
|
||||
| behavior::scenario::CanvasEvent::Viewport(..)
|
||||
| behavior::scenario::CanvasEvent::Restored(..)
|
||||
| behavior::scenario::CanvasEvent::Scrolled(..)
|
||||
| behavior::scenario::CanvasEvent::Camera(..)
|
||||
) && current.epoch != captured.epoch)
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
// Passive layout/form reports retain their existing epoch rules;
|
||||
// camera input uses its separate lifetime below. All other actions
|
||||
// must match the exact context before shared reveal reconciliation.
|
||||
if let behavior::scenario::CanvasEvent::Camera(_, lifetime, _) = event
|
||||
&& *lifetime != self.camera_epoch
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// A retained Details button may belong to an earlier selection or
|
||||
// canvas epoch. Refuse it before retiring any newer reveal or draft.
|
||||
if let behavior::scenario::CanvasEvent::Properties(_, id) = event
|
||||
&& (self.scenario_context() != Some(captured)
|
||||
|| self.selected != *id
|
||||
|| !self.project().contains(*id)
|
||||
|| !behavior::scenario::properties_target(self.project(), captured, *id)
|
||||
|| self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
|| self.source_dirty()
|
||||
|| self.source_rename.open()
|
||||
|| !behavior::canvas_workspace::keyboard_enabled(self))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
let usage_viewport = self.diagram_reveal_viewport(&message);
|
||||
let window_resized = matches!(&message, Message::WindowResized(_));
|
||||
// Reject stale measurements before legacy viewport handlers can change
|
||||
@@ -1394,6 +1497,7 @@ impl App {
|
||||
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(..))
|
||||
);
|
||||
let before = self.camera_boundary();
|
||||
let spacing_selected = self.selected;
|
||||
let task = self.update_inner(message);
|
||||
if self.allocation_boundary() != allocation_before {
|
||||
self.allocation_retire();
|
||||
@@ -1411,6 +1515,13 @@ impl App {
|
||||
{
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
if (self.camera_boundary() != before
|
||||
|| self.selected != spacing_selected
|
||||
|| !self.spacing_enabled())
|
||||
&& self.behavior.scenario.cancel_spacing()
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
if !camera && self.camera_boundary() != before {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
@@ -1434,6 +1545,7 @@ impl App {
|
||||
let valid_scenario = |c: behavior::scenario::Context| {
|
||||
c.document == scope.document
|
||||
&& c.scenario == scope.owner
|
||||
&& scope.scenario_view == Some((c.representation, c.visit))
|
||||
&& c.revision == scope.revision
|
||||
&& self.camera_toolbar_epoch(c.epoch)
|
||||
};
|
||||
@@ -1461,6 +1573,9 @@ impl App {
|
||||
_ => return Task::none(),
|
||||
}
|
||||
let task = self.update(message);
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
// Reapplying Fit or a tool also ends a held gesture. Focus is stamped
|
||||
// after the action, for Iced's final-view ordering.
|
||||
if self.camera_epoch == scope.epoch {
|
||||
@@ -1827,6 +1942,7 @@ impl App {
|
||||
self.refresh_source_rename_context();
|
||||
self.reconcile_reference_chooser();
|
||||
self.route_state_diagram_workspace();
|
||||
self.reconcile_scenario_workspace();
|
||||
if self.page != Page::Home {
|
||||
self.last_workspace_page = self.page;
|
||||
}
|
||||
@@ -1835,11 +1951,6 @@ impl App {
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
}
|
||||
if let Some(context) = self.scenario_context() {
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter(context.document, context.scenario);
|
||||
}
|
||||
if self.connection.as_ref().is_some_and(|session| {
|
||||
Some(session.context) != self.route_context()
|
||||
|| self.page != Page::Diagram
|
||||
@@ -1883,15 +1994,23 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
|
||||
let scenario = crate::behavior::selected_owner(self)?;
|
||||
(self.page == Page::Behavior
|
||||
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
|
||||
.then_some(crate::behavior::scenario::Context {
|
||||
document: self.canvas_document,
|
||||
scenario,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
if self.page != Page::Behavior {
|
||||
return None;
|
||||
}
|
||||
let scenario = if matches!(
|
||||
self.behavior.scenario.representation(),
|
||||
Some(behavior::scenario_layout::Representation::Saved { .. })
|
||||
) {
|
||||
self.behavior.scenario.owner()?
|
||||
} else {
|
||||
crate::behavior::selected_owner(self)?
|
||||
};
|
||||
self.behavior.scenario.context(
|
||||
self.canvas_document,
|
||||
scenario,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
)
|
||||
}
|
||||
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
|
||||
if self.busy
|
||||
@@ -1907,7 +2026,46 @@ impl App {
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
fn saved_scenario_diagram(&self, id: Id) -> bool {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.scenario().is_some())
|
||||
}
|
||||
|
||||
fn unsupported_saved_scenario_message(&self, message: &Message) -> bool {
|
||||
let active = self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id));
|
||||
match message {
|
||||
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
|
||||
Message::Projection(projection) => active || projection.scenario().is_some(),
|
||||
Message::SelectNode(_)
|
||||
| Message::PlaceSelected
|
||||
| Message::HideSelected
|
||||
| Message::ToggleCollapse
|
||||
| Message::MoveNode(..)
|
||||
| Message::Route(_)
|
||||
| Message::Connection(_)
|
||||
| Message::PortAnchor(_)
|
||||
| Message::Export
|
||||
| Message::Fit
|
||||
| Message::Zoom(_)
|
||||
| Message::Pan(_) => active,
|
||||
Message::Add(_) | Message::CreateContextElement(..) => {
|
||||
active && self.page == Page::Diagram
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
// Refuse unsupported saved-view actions before they can Apply unrelated drafts.
|
||||
// Generic model/source edits and the semantic Scenario workspace remain available.
|
||||
if self.unsupported_saved_scenario_message(&message) {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
match message {
|
||||
Message::WorkDrag(event) => return self.allocation_event(event),
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
@@ -1930,6 +2088,8 @@ impl App {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.representation == context.representation
|
||||
&& current.visit == context.visit
|
||||
&& current.revision == context.revision
|
||||
}) && self.scenario_focus_owner().is_some()
|
||||
&& self.behavior.scenario.take_form_focus(mount)
|
||||
@@ -1942,6 +2102,8 @@ impl App {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.representation == context.representation
|
||||
&& current.visit == context.visit
|
||||
&& current.revision == context.revision
|
||||
}) && self.behavior.scenario.viewport(size)
|
||||
{
|
||||
@@ -1951,7 +2113,10 @@ impl App {
|
||||
}
|
||||
if let CanvasEvent::Restored(_, token, area, offset) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document && current.scenario == context.scenario
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.representation == context.representation
|
||||
&& current.visit == context.visit
|
||||
}) && self
|
||||
.behavior
|
||||
.scenario
|
||||
@@ -1968,6 +2133,8 @@ impl App {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document
|
||||
&& current.scenario == context.scenario
|
||||
&& current.representation == context.representation
|
||||
&& current.visit == context.visit
|
||||
&& current.revision == context.revision
|
||||
}) && self.behavior.scenario.scrolled(area, offset)
|
||||
{
|
||||
@@ -1981,6 +2148,8 @@ impl App {
|
||||
|| !self.scenario_context().is_some_and(|c| {
|
||||
c.document == context.document
|
||||
&& c.scenario == context.scenario
|
||||
&& c.representation == context.representation
|
||||
&& c.visit == context.visit
|
||||
&& c.revision == context.revision
|
||||
})
|
||||
{
|
||||
@@ -1989,6 +2158,7 @@ impl App {
|
||||
let scope = canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
};
|
||||
@@ -1999,6 +2169,9 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
match input {
|
||||
Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
@@ -2010,7 +2183,10 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1)
|
||||
}
|
||||
input => {
|
||||
if self.behavior.scenario.camera(input) {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let changed = state.camera(self.project(), context.scenario, input);
|
||||
self.behavior.scenario = state;
|
||||
if changed {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
}
|
||||
@@ -2035,10 +2211,56 @@ impl App {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Representation(_, requested) => {
|
||||
use behavior::scenario_layout::{
|
||||
Representation, presentation::checked_owner,
|
||||
};
|
||||
if checked_owner(self.project(), requested) != Some(context.scenario) {
|
||||
return Task::none();
|
||||
}
|
||||
// An explicitly selected current view record follows
|
||||
// the arrangement picker, as with the Diagram picker.
|
||||
// Independent occurrence/lifeline selection stays put.
|
||||
let follow_view = matches!(context.representation,
|
||||
Representation::Saved { diagram } if self.selected == diagram);
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let requested = match requested {
|
||||
Representation::Saved { diagram }
|
||||
if context.representation
|
||||
== (Representation::Saved { diagram }) =>
|
||||
{
|
||||
Representation::Saved {
|
||||
diagram: self.active_diagram.unwrap_or(diagram),
|
||||
}
|
||||
}
|
||||
Representation::Derived { owner } => Representation::Derived {
|
||||
owner: self.behavior.active_owner.unwrap_or(owner),
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
if self.enter_scenario_representation(requested) && follow_view {
|
||||
self.selected = match requested {
|
||||
Representation::Saved { diagram } => diagram,
|
||||
// Automatic has no Diagram record; its Scenario
|
||||
// owner is the corresponding inspectable target.
|
||||
Representation::Derived { owner } => owner,
|
||||
};
|
||||
self.refresh_draft();
|
||||
}
|
||||
}
|
||||
CanvasEvent::Shelf(_, shelf) => {
|
||||
self.behavior.scenario.toggle_shelf(shelf);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Properties(_, _) => {
|
||||
// The checked action opens the current record only: no
|
||||
// select()/Apply side effect or representation change.
|
||||
self.behavior.scenario.show_properties();
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Zoom(_, scale) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
if state.camera_zoom(self.project(), context.scenario, scale) {
|
||||
@@ -2059,7 +2281,11 @@ impl App {
|
||||
Ok(target) => {
|
||||
// A command can preselect this target while the
|
||||
// caller remains visible, bypassing select below.
|
||||
self.behavior.active_owner = Some(target);
|
||||
self.enter_scenario_representation(
|
||||
behavior::scenario_layout::Representation::Derived {
|
||||
owner: target,
|
||||
},
|
||||
);
|
||||
self.select(target);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
@@ -2081,7 +2307,7 @@ impl App {
|
||||
// A command may already have selected this UUID while
|
||||
// leaving the referenced definition open. select's
|
||||
// same-UUID fast path must not keep that old owner.
|
||||
self.behavior.active_owner = Some(caller.owner);
|
||||
self.enter_scenario_representation(caller.representation);
|
||||
self.select(caller.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if let Some(context) = self.scenario_context() {
|
||||
@@ -2109,6 +2335,15 @@ impl App {
|
||||
}
|
||||
}
|
||||
CanvasEvent::Choose(_, tool) => {
|
||||
if tool != scenario::CanvasTool::Select
|
||||
&& !self
|
||||
.behavior
|
||||
.scenario
|
||||
.coordinate_input(self.project(), context)
|
||||
{
|
||||
self.status = "Canvas insertion and reordering are unavailable for this presentation.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
{
|
||||
self.status =
|
||||
@@ -2120,6 +2355,17 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
mutation => {
|
||||
if !behavior::scenario_layout::presentation::present(
|
||||
self.project(),
|
||||
context.representation,
|
||||
self.selected,
|
||||
)
|
||||
.capabilities()
|
||||
.coordinate_input
|
||||
{
|
||||
self.status = "Layout editing is unavailable for this presentation. Saved positions are preserved.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
{
|
||||
self.status =
|
||||
@@ -2220,6 +2466,13 @@ impl App {
|
||||
})
|
||||
{
|
||||
self.behavior.active_owner = Some(choice.id);
|
||||
if self.project().elements[&choice.id].kind() == Kind::Scenario {
|
||||
self.enter_scenario_representation(
|
||||
behavior::scenario_layout::Representation::Derived { owner: choice.id },
|
||||
);
|
||||
} else {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
self.behavior.diagram = Default::default();
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.select(choice.id);
|
||||
@@ -2587,7 +2840,7 @@ impl App {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.state_machine().is_some())
|
||||
.is_some_and(|d| !d.projection.is_architecture())
|
||||
})
|
||||
{
|
||||
// Architecture remains reachable from a state workspace.
|
||||
@@ -2596,9 +2849,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.layer == self.layer && d.projection.state_machine().is_none()
|
||||
})
|
||||
.filter(|d| d.layer == self.layer && d.projection.is_architecture())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
self.fit();
|
||||
@@ -2609,6 +2860,15 @@ impl App {
|
||||
crate::behavior::selected_behavior(self.project(), self.selected)
|
||||
{
|
||||
self.behavior.active_owner = Some(owner);
|
||||
if self.project().elements[&owner].kind() == Kind::Scenario
|
||||
&& self.behavior.scenario.owner() != Some(owner)
|
||||
{
|
||||
self.enter_scenario_representation(
|
||||
behavior::scenario_layout::Representation::Derived { owner },
|
||||
);
|
||||
} else if self.project().elements[&owner].kind() != Kind::Scenario {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
}
|
||||
self.behavior.active_owner = crate::behavior::selected_owner(self);
|
||||
self.reset_state_view_after_navigation(previous_state_view);
|
||||
@@ -2845,6 +3105,7 @@ impl App {
|
||||
}
|
||||
Message::Layer(layer) => {
|
||||
if self.apply() {
|
||||
self.behavior.scenario.forget_view();
|
||||
let previous_state_view = self.state_view_identity();
|
||||
self.layer = layer;
|
||||
self.create_kind = if layer == Layer::Oa {
|
||||
@@ -2856,7 +3117,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
self.selected = self.project().id;
|
||||
@@ -3017,6 +3278,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::RevealInDiagram(diagram, selected) => {
|
||||
if self.saved_scenario_diagram(diagram) {
|
||||
return self.reveal_saved_scenario(diagram, selected);
|
||||
}
|
||||
let original = self.selected;
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
@@ -3029,6 +3293,10 @@ impl App {
|
||||
let Some(view) = self.project().diagrams.get(&diagram) else {
|
||||
return Task::none();
|
||||
};
|
||||
if view.projection.scenario().is_some() {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
let layer = view.layer;
|
||||
let endpoints = self
|
||||
.project()
|
||||
@@ -3096,6 +3364,7 @@ impl App {
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: ProjectionKind::Allocation,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -3270,6 +3539,16 @@ impl App {
|
||||
self.status = "Diagram no longer exists.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.saved_scenario_diagram(choice.id)
|
||||
&& behavior::scenario_layout::presentation::checked_owner(
|
||||
self.project(),
|
||||
behavior::scenario_layout::Representation::Saved { diagram: choice.id },
|
||||
)
|
||||
.is_none()
|
||||
{
|
||||
self.status = "This Scenario view has an incompatible or missing owner.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let previous_diagram = self.active_diagram;
|
||||
let follow_view = previous_diagram == Some(self.selected);
|
||||
if self.apply() {
|
||||
@@ -3289,7 +3568,14 @@ impl App {
|
||||
}
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.fit();
|
||||
if self.saved_scenario_diagram(diagram) {
|
||||
self.enter_scenario_representation(
|
||||
behavior::scenario_layout::Representation::Saved { diagram },
|
||||
);
|
||||
} else {
|
||||
self.page = Page::Diagram;
|
||||
self.fit();
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Projection(projection) => {
|
||||
@@ -3299,6 +3585,10 @@ impl App {
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.cloned()
|
||||
{
|
||||
if d.projection.scenario().is_some() {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
d.projection = projection;
|
||||
self.execute(Command::EditDiagram(d.id, d));
|
||||
self.fit();
|
||||
@@ -3425,10 +3715,16 @@ impl App {
|
||||
self.pan += delta;
|
||||
}
|
||||
Message::Fit => self.fit(),
|
||||
Message::SourceEdit(action) => {
|
||||
Message::SourceEdit(_) | Message::SourceIndent(..) => {
|
||||
self.source_jump = None;
|
||||
let before = self.source_editor.text();
|
||||
self.source_editor.perform(action);
|
||||
match message {
|
||||
Message::SourceEdit(action) => self.source_editor.perform(action),
|
||||
Message::SourceIndent(_, outdent) => {
|
||||
crate::source_indentation::apply(&mut self.source_editor, outdent);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let after = self.source_editor.text();
|
||||
if after != before {
|
||||
self.source_navigation.refresh(&after);
|
||||
@@ -3891,6 +4187,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::Export => {
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
if !self.project().diagrams.contains_key(&diagram) {
|
||||
self.status = crate::diagram::ExportError::MissingDiagram(diagram).to_string();
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply() {
|
||||
return self.choose_file(FileOperation::Export);
|
||||
}
|
||||
@@ -3953,7 +4257,9 @@ impl App {
|
||||
};
|
||||
let id = e.id;
|
||||
let mut commands = vec![Command::CreateElement(e)];
|
||||
let mut diagram_target = self.active_diagram;
|
||||
let mut diagram_target = self
|
||||
.active_diagram
|
||||
.filter(|id| !self.saved_scenario_diagram(*id));
|
||||
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
|
||||
Some(ProjectionKind::Allocation)
|
||||
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
|
||||
@@ -3983,6 +4289,7 @@ impl App {
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: preferred,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -4282,11 +4589,24 @@ impl App {
|
||||
)
|
||||
}
|
||||
fn export_to(&mut self, path: PathBuf) -> Task<Message> {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let svg = crate::diagram::export_svg(self.project(), diagram);
|
||||
let svg = match crate::diagram::export_svg(self.project(), diagram) {
|
||||
Ok(svg) => svg,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.busy = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
@@ -4525,6 +4845,9 @@ impl App {
|
||||
)
|
||||
}
|
||||
pub fn route_context(&self) -> Option<crate::route_edit::Context> {
|
||||
if self.saved_scenario_diagram(self.active_diagram?) {
|
||||
return None;
|
||||
}
|
||||
Some(crate::route_edit::Context {
|
||||
document: self.canvas_document,
|
||||
diagram: self.active_diagram?,
|
||||
@@ -4818,6 +5141,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn fit(&mut self) {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.fit_to_view = true;
|
||||
let view = self
|
||||
.active_diagram
|
||||
@@ -4865,7 +5194,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
|
||||
return p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| {
|
||||
let view = project_view(p, d.id);
|
||||
(
|
||||
@@ -4879,7 +5208,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
|
||||
}
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| {
|
||||
(
|
||||
!p.elements
|
||||
@@ -5960,6 +6289,7 @@ mod port_view_tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -6126,3 +6456,240 @@ mod data_paging_lifecycle_tests {
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_scenario_dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let f = behavior::scenario::direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let architecture = project.ensure_diagram(Layer::Oa);
|
||||
let saved = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
saved,
|
||||
Diagram {
|
||||
id: saved,
|
||||
alias: "saved_scenario_dispatch".into(),
|
||||
name: "Saved Scenario".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.select(f.owner);
|
||||
(app, architecture, saved, f.owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_export_schedules_no_write_and_preserves_draft_history_and_busy_state() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep export draft".into()));
|
||||
assert!(app.draft_dirty());
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("arcadia-rejected-export-{}.svg", Id::new_v4()));
|
||||
std::fs::write(&path, b"existing export").unwrap();
|
||||
for target in [Some(saved), Some(Id::new_v4()), None] {
|
||||
for busy in [false, true] {
|
||||
app.active_diagram = target;
|
||||
app.busy = busy;
|
||||
let before = app.document.bundle().clone();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
);
|
||||
let view = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
);
|
||||
let task = app.export_to(path.clone());
|
||||
assert_eq!(task.units(), 0, "rejection must not schedule a write");
|
||||
assert_eq!(app.busy, busy);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo()
|
||||
),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan
|
||||
),
|
||||
view
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing export");
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_export_target_does_not_apply_pending_properties_or_open_dialog() {
|
||||
for target in [None, Some(Id::new_v4())] {
|
||||
let (mut app, _, _, _) = fixture();
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied export draft".into(),
|
||||
));
|
||||
app.active_diagram = target;
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let task = app.handle(Message::Export);
|
||||
assert_eq!(task.units(), 0);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_picker_with_invalid_properties_and_reveal_with_pending_properties_do_not_apply() {
|
||||
for reveal in [false, true] {
|
||||
let (mut app, architecture, saved, owner) = fixture();
|
||||
let _ = app.update(Message::Field(
|
||||
if reveal { "/name" } else { "/id" }.into(),
|
||||
if reveal {
|
||||
"Unapplied scenario name"
|
||||
} else {
|
||||
"-"
|
||||
}
|
||||
.into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let selected = app.selected;
|
||||
let message = if reveal {
|
||||
Message::RevealInDiagram(saved, owner)
|
||||
} else {
|
||||
Message::Diagram(Choice {
|
||||
id: saved,
|
||||
label: "Saved Scenario".into(),
|
||||
})
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_cannot_use_architecture_controls_or_change_projection_by_toolbar() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
app.active_diagram = Some(saved);
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep pending".into()));
|
||||
let before = app.document.bundle().clone();
|
||||
assert!(app.route_context().is_none());
|
||||
assert!(architecture_authoring::kinds(&app).is_empty());
|
||||
for message in [
|
||||
Message::Projection(ProjectionKind::Overview),
|
||||
Message::PlaceSelected,
|
||||
Message::Command(Command::Layout(saved)),
|
||||
Message::Export,
|
||||
] {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(
|
||||
app.project().diagrams[&saved]
|
||||
.projection
|
||||
.scenario()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_does_not_start_the_existing_architecture_palette() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
app.active_diagram = Some(saved);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Architecture(
|
||||
architecture_authoring::Event::Existing(None),
|
||||
));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.status.contains("architecture view"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_saved_usage_is_refused_without_selection_or_camera_changes() {
|
||||
let (mut app, architecture, saved, _) = fixture();
|
||||
let before = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.layer,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
app.canvas_epoch,
|
||||
);
|
||||
let source = app.document.bundle().clone();
|
||||
let usage = crate::diagram_usage::Usage {
|
||||
subject: saved,
|
||||
surface: crate::diagram_usage::Surface::Saved(saved),
|
||||
target: crate::diagram_usage::Target::Whole,
|
||||
role: "diagram".into(),
|
||||
bounds: iced::Rectangle::new(iced::Point::new(500., 600.), iced::Size::new(300., 200.)),
|
||||
};
|
||||
assert!(!app.reveal_diagram_usage(&usage));
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.layer,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
app.canvas_epoch
|
||||
),
|
||||
before
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_scenario_preview_remains_available_and_saved_views_are_not_default_architecture() {
|
||||
let (mut app, architecture, saved, owner) = fixture();
|
||||
assert_eq!(
|
||||
preferred_diagram(app.project(), Some(saved), Layer::Oa, owner),
|
||||
Some(architecture)
|
||||
);
|
||||
app.active_diagram = Some(saved);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, owner);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let mut only_saved = app.project().clone();
|
||||
only_saved.diagrams.retain(|id, _| *id == saved);
|
||||
assert!(preferred_diagram(&only_saved, None, Layer::Oa, owner).is_none());
|
||||
assert!(App::with_project(only_saved).active_diagram.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,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 +180,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 +208,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 +258,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() {
|
||||
|
||||
@@ -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),
|
||||
@@ -335,7 +404,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.),
|
||||
@@ -377,19 +449,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;
|
||||
@@ -416,6 +483,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,
|
||||
@@ -436,10 +515,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
|
||||
|
||||
@@ -1400,3 +1400,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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -57,8 +57,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(
|
||||
@@ -66,7 +86,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;
|
||||
@@ -1348,7 +1368,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
|
||||
@@ -1363,7 +1394,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"
|
||||
@@ -1768,14 +1813,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),
|
||||
|
||||
@@ -2631,6 +2631,7 @@ mod tests {
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
|
||||
@@ -379,6 +379,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
|
||||
layer: owner.layer(),
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
|
||||
@@ -219,6 +219,7 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
|
||||
graph.interaction = Some((context, state.tool, clean));
|
||||
let scale = graph.scale;
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
scenario_view: None,
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
|
||||
@@ -1927,11 +1927,32 @@ fn hex(c: Color) -> String {
|
||||
(c.b * 255.) as u8
|
||||
)
|
||||
}
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExportError {
|
||||
MissingDiagram(Id),
|
||||
UnsupportedScenario(Id),
|
||||
}
|
||||
impl std::fmt::Display for ExportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingDiagram(id) => write!(f, "Cannot export: diagram {id} no longer exists."),
|
||||
Self::UnsupportedScenario(_) => {
|
||||
f.write_str("SVG export of saved Scenario views is not available yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ExportError {}
|
||||
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
|
||||
let d = p
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or(ExportError::MissingDiagram(diagram))?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ExportError::UnsupportedScenario(diagram));
|
||||
}
|
||||
let view = project_view(p, diagram);
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
return String::new();
|
||||
};
|
||||
let routed = route_diagram(p, d, &view);
|
||||
let left = routed.bounds.position.x - 30.;
|
||||
let top = routed.bounds.position.y - 30.;
|
||||
@@ -2070,7 +2091,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -2080,6 +2101,83 @@ mod pointer_tests;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn svg_rejects_saved_scenario_with_absent_or_authored_layout_without_mutation() {
|
||||
let f = crate::behavior::scenario::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
d.projection = ProjectionKind::Scenario { scenario: f.owner };
|
||||
d.nodes.clear();
|
||||
d.routes.clear();
|
||||
for layout in [
|
||||
None,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: 240.,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.call,
|
||||
},
|
||||
send_y: 180.,
|
||||
}],
|
||||
})),
|
||||
] {
|
||||
p.diagrams.get_mut(&diagram).unwrap().layout = layout;
|
||||
let admitted = execute_batch(&p, &[]).expect("valid saved Scenario fixture");
|
||||
assert_eq!(admitted, p);
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
export_svg(&p, diagram),
|
||||
Err(ExportError::UnsupportedScenario(diagram))
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_rejects_missing_diagram_without_mutation() {
|
||||
let p = Project::demo();
|
||||
let missing = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
export_svg(&p, missing),
|
||||
Err(ExportError::MissingDiagram(missing))
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_accepts_populated_and_empty_architecture_views() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.projection.is_architecture() && !d.nodes.is_empty())
|
||||
.unwrap()
|
||||
.id;
|
||||
let before = p.clone();
|
||||
let svg = export_svg(&p, diagram).expect("populated architecture");
|
||||
assert!(svg.starts_with("<svg "));
|
||||
assert!(svg.contains("<text "));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert_eq!(p, before);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
d.projection = ProjectionKind::Overview;
|
||||
d.nodes.clear();
|
||||
d.routes.clear();
|
||||
assert_eq!(execute_batch(&p, &[]).expect("valid empty architecture"), p);
|
||||
let before = p.clone();
|
||||
let svg = export_svg(&p, diagram).expect("empty architecture is supported");
|
||||
assert!(svg.contains("viewBox=\"-30 -30 60 60\""));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
|
||||
let p = Project::demo();
|
||||
@@ -2113,7 +2211,7 @@ mod tests {
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let title = format!("<title>{}</title>", escape(&boundary.name));
|
||||
let edge_path = svg
|
||||
.split(&title)
|
||||
@@ -2187,7 +2285,7 @@ mod tests {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let group = svg
|
||||
.split("<g>")
|
||||
.find(|g| g.contains(">Grouped work</text>"))
|
||||
@@ -2280,7 +2378,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = project_view(&p, diagram);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
for edge in &view.edges {
|
||||
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
|
||||
}
|
||||
@@ -2295,7 +2393,7 @@ mod tests {
|
||||
p.place(d, a, Position { x: 0., y: 0. });
|
||||
p.place(d, b, Position { x: 400., y: 0. });
|
||||
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
|
||||
let svg = export_svg(&p, d);
|
||||
let svg = export_svg(&p, d).expect("supported diagram");
|
||||
assert!(svg.contains("«include»</text>"));
|
||||
assert!(svg.contains("stroke-dasharray=\"6 4\""));
|
||||
}
|
||||
@@ -2385,7 +2483,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(moved.position.x, port.position.x + 100.);
|
||||
assert_eq!(moved.position.y, port.position.y + 70.);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
for port in &view.ports {
|
||||
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
|
||||
}
|
||||
@@ -2858,7 +2956,7 @@ mod tests {
|
||||
});
|
||||
let view = project_view(&p, diagram);
|
||||
let geometry = route_diagram(&p, &p.diagrams[&diagram], &view);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let route = &geometry.edges[&edge];
|
||||
let path = route
|
||||
.points
|
||||
@@ -2918,7 +3016,7 @@ mod tests {
|
||||
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
|
||||
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
|
||||
assert_eq!(ends.len(), 2);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(svg.contains("parent [0..1]</text>"));
|
||||
assert!(svg.contains("children [0..*]</text>"));
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
|
||||
@@ -27,7 +27,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
validate_candidate(&p).unwrap();
|
||||
let initial = p.clone();
|
||||
let path_key = format!("<path data-edge=\"{edge}\"");
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let edge_path = svg
|
||||
.split(&path_key)
|
||||
.nth(1)
|
||||
@@ -46,7 +46,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.unwrap()
|
||||
.view_options
|
||||
.exchange_labels = ExchangeLabels::Items;
|
||||
let items_svg = export_svg(&p, diagram);
|
||||
let items_svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(items_svg.contains("Data & status"));
|
||||
let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
@@ -54,7 +54,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
unreachable!()
|
||||
};
|
||||
*oriented = true;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(
|
||||
svg.split(&path_key)
|
||||
.nth(1)
|
||||
@@ -71,7 +71,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert!(
|
||||
!export_svg(&p, diagram)
|
||||
.expect("supported diagram")
|
||||
.contains(&path_key)
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
@@ -83,7 +87,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert!(
|
||||
!export_svg(&p, diagram)
|
||||
.expect("supported diagram")
|
||||
.contains(&path_key)
|
||||
);
|
||||
assert_eq!(p.diagrams[&diagram].nodes, placements);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
|
||||
@@ -170,6 +170,13 @@ impl Surface {
|
||||
{
|
||||
"State views"
|
||||
}
|
||||
Self::Saved(id)
|
||||
if p.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.scenario().is_some()) =>
|
||||
{
|
||||
"Scenarios"
|
||||
}
|
||||
Self::Saved(_) => "Architecture",
|
||||
Self::Process(_) => "Processes and chains",
|
||||
Self::Scenario(_) => "Scenarios",
|
||||
@@ -276,6 +283,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
|
||||
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Some(diagram) = only_diagram
|
||||
.and_then(|id| p.diagrams.get(&id))
|
||||
.filter(|d| d.projection.scenario().is_some())
|
||||
{
|
||||
return crate::behavior::scenario::State::default().representation_usages(
|
||||
p,
|
||||
crate::behavior::scenario_layout::Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
subject,
|
||||
);
|
||||
}
|
||||
let index = ViewIndex::new(p);
|
||||
let mut usages = Vec::new();
|
||||
for diagram in p
|
||||
@@ -283,6 +302,18 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
|
||||
.values()
|
||||
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
|
||||
{
|
||||
if diagram.projection.scenario().is_some() {
|
||||
usages.extend(
|
||||
crate::behavior::scenario::State::default().representation_usages(
|
||||
p,
|
||||
crate::behavior::scenario_layout::Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
subject,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let view = projection::project_diagram_with_index(p, diagram, &index);
|
||||
let surface = Surface::Saved(diagram.id);
|
||||
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
|
||||
|
||||
@@ -1535,7 +1535,8 @@ fn group_label(path: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
|
||||
(diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
|
||||
diagram.projection.scenario().is_none()
|
||||
&& (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
|
||||
&& diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
@@ -2683,6 +2684,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
|
||||
@@ -29,6 +29,7 @@ mod recovery;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_annotations;
|
||||
mod source_indentation;
|
||||
mod source_navigation;
|
||||
mod source_rename;
|
||||
mod source_tree;
|
||||
|
||||
@@ -813,6 +813,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Source indentation is one workspace edit, with physical line endings intact.
|
||||
use iced::{
|
||||
advanced::{graphics, text},
|
||||
widget::text_editor::{Action, Content, Cursor, Edit, Position},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
const INDENT: &str = " ";
|
||||
|
||||
/// Plain Tab inserts one canonical four-space level at a caret, or prefixes
|
||||
/// every selected line. Shift+Tab removes one leading tab or up to four spaces.
|
||||
/// A selection ending at column zero excludes that final line. All patches are
|
||||
/// local to lines, so CRLF, LF and Unicode bytes outside indentation survive.
|
||||
/// The caller records the resulting buffer once in workspace undo/history.
|
||||
pub fn apply<R>(content: &mut Content<R>, outdent: bool)
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection().is_some_and(|s| !s.is_empty());
|
||||
if !selected && !outdent {
|
||||
content.perform(Action::Edit(Edit::Paste(Arc::new(INDENT.into()))));
|
||||
return;
|
||||
}
|
||||
// Determine affected lines before normalizing Word/Line mode. Those modes
|
||||
// expand columns within their endpoint lines; the copied LF count reveals
|
||||
// whether a zero-column endpoint includes the next line or only its edge.
|
||||
let anchor = raw.selection.unwrap_or(raw.position);
|
||||
let first = raw.position.line.min(anchor.line);
|
||||
let mut last = raw.position.line.max(anchor.line);
|
||||
if selected {
|
||||
let selected_text = content.selection().unwrap();
|
||||
let line_breaks = selected_text.bytes().filter(|b| *b == b'\n').count();
|
||||
// Effective selection starts on the minimum raw endpoint line.
|
||||
last = first + line_breaks;
|
||||
if last > first && selected_text.ends_with('\n') {
|
||||
last -= 1;
|
||||
}
|
||||
}
|
||||
let patches: Vec<_> = (first..=last)
|
||||
.filter_map(|line| {
|
||||
let removed = if outdent {
|
||||
let text = content.line(line)?;
|
||||
if text.text.starts_with('\t') {
|
||||
1
|
||||
} else {
|
||||
text.text.bytes().take(4).take_while(|b| *b == b' ').count()
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(!outdent || removed > 0).then_some((line, removed))
|
||||
})
|
||||
.collect();
|
||||
if patches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cursor = crate::source_navigation::selection::capture_for_edit(content);
|
||||
let replacement = Arc::new(if outdent {
|
||||
String::new()
|
||||
} else {
|
||||
INDENT.into()
|
||||
});
|
||||
// Descending order leaves Iced's earliest changed line at the first patch.
|
||||
for &(line, removed) in patches.iter().rev() {
|
||||
content.move_to(Cursor {
|
||||
position: Position { line, column: 0 },
|
||||
selection: Some(Position {
|
||||
line,
|
||||
column: removed,
|
||||
}),
|
||||
});
|
||||
content.perform(Action::Edit(Edit::Paste(replacement.clone())));
|
||||
}
|
||||
let transform = |mut position: Position| {
|
||||
if let Some((_, removed)) = patches.iter().find(|(line, _)| *line == position.line) {
|
||||
position.column = if outdent {
|
||||
position.column.saturating_sub(*removed)
|
||||
} else {
|
||||
position.column + INDENT.len()
|
||||
};
|
||||
}
|
||||
position
|
||||
};
|
||||
content.move_to(Cursor {
|
||||
position: transform(cursor.position),
|
||||
selection: cursor.selection.map(transform),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod keyboard_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Real mounted Iced keyboard dispatch, using the production binding function.
|
||||
use crate::{app::Message, source_navigation::SymbolContext, views::source_tests::FontLayout};
|
||||
use iced::advanced::widget::operation::Focusable;
|
||||
use iced::{
|
||||
Element, Font, Rectangle, Size,
|
||||
advanced::{Layout, Shell, layout, text, widget as core_widget},
|
||||
keyboard::{
|
||||
self, Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
},
|
||||
widget::{
|
||||
self,
|
||||
text_editor::{Content, State},
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mounted_tab_dispatches_only_for_focused_editable_editor_and_plain_shift_variants() {
|
||||
for focused in [false, true] {
|
||||
for editable in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::CTRL,
|
||||
Modifiers::ALT,
|
||||
Modifiers::LOGO,
|
||||
] {
|
||||
let context = SymbolContext {
|
||||
session: oa_core::Id::new_v4(),
|
||||
path: "project.arcadia".into(),
|
||||
};
|
||||
let content = Content::<FontLayout>::with_text("rain");
|
||||
let mut editor = widget::text_editor(&content)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(iced::Fill)
|
||||
.key_binding(|press| crate::views::source_binding(press, &context, None));
|
||||
if editable {
|
||||
editor = editor.on_action(Message::SourceEdit);
|
||||
}
|
||||
let mut view: Element<'_, Message, iced::Theme, FontLayout> = editor.into();
|
||||
let mut tree = core_widget::Tree::new(view.as_widget());
|
||||
let size = Size::new(600., 240.);
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, size),
|
||||
);
|
||||
if focused {
|
||||
tree.state
|
||||
.downcast_mut::<State<text::highlighter::PlainText>>()
|
||||
.focus();
|
||||
}
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(Named::Tab),
|
||||
modified_key: Key::Named(Named::Tab),
|
||||
physical_key: Physical::Code(Code::Tab),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
let captured;
|
||||
{
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
captured = shell.is_event_captured();
|
||||
}
|
||||
let plain = modifiers.is_empty() || modifiers == Modifiers::SHIFT;
|
||||
let expected = focused && editable && plain;
|
||||
let indentation: Vec<_> = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
Message::SourceIndent(emitted, outdent) => Some((emitted, outdent)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
indentation.len(),
|
||||
usize::from(expected),
|
||||
"focused={focused}, editable={editable}, modifiers={modifiers:?}"
|
||||
);
|
||||
if expected {
|
||||
assert!(captured, "Tab must not escape to global focus traversal");
|
||||
assert_eq!(indentation[0], (&context, &modifiers.shift()));
|
||||
} else if !focused || !editable {
|
||||
assert!(
|
||||
!captured,
|
||||
"non-editing Tab must remain available to focus traversal"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::*;
|
||||
use iced::widget::text_editor::Motion;
|
||||
|
||||
type Buffer = Content<iced::Renderer>;
|
||||
fn at(line: usize, column: usize) -> Position {
|
||||
Position { line, column }
|
||||
}
|
||||
fn range(start: Position, end: Position, reverse: bool) -> Cursor {
|
||||
Cursor {
|
||||
position: if reverse { start } else { end },
|
||||
selection: Some(if reverse { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_inserts_four_spaces_and_outdent_removes_only_leading_whitespace() {
|
||||
let mut content = Buffer::with_text(" 雨🚀\r\nnext");
|
||||
content.move_to(Cursor {
|
||||
position: at(0, " 雨".len()),
|
||||
selection: None,
|
||||
});
|
||||
apply(&mut content, false);
|
||||
assert_eq!(content.text(), " 雨 🚀\r\nnext");
|
||||
assert_eq!(content.cursor().position, at(0, " 雨 ".len()));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "雨 🚀\r\nnext");
|
||||
assert_eq!(content.cursor().position, at(0, "雨 ".len()));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "雨 🚀\r\nnext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_blocks_preserve_unicode_endings_and_exclude_final_zero_column() {
|
||||
let source = "雨\r\n🚀\nlast\r\n";
|
||||
for reverse in [false, true] {
|
||||
let mut content = Buffer::with_text(source);
|
||||
let original = range(at(0, 0), at(2, 0), reverse);
|
||||
content.move_to(original);
|
||||
apply(&mut content, false);
|
||||
assert_eq!(content.text(), " 雨\r\n 🚀\nlast\r\n");
|
||||
assert_eq!(content.cursor(), range(at(0, 4), at(2, 0), reverse));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), source);
|
||||
assert_eq!(content.cursor(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdent_handles_tabs_partial_spaces_and_clamps_endpoint_inside_removed_indent() {
|
||||
for reverse in [false, true] {
|
||||
let mut content = Buffer::with_text("\t雨\r\n 🚀\n fin");
|
||||
content.move_to(range(at(0, 0), at(2, 2), reverse));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "雨\r\n🚀\nfin");
|
||||
assert_eq!(content.cursor(), range(at(0, 0), at(2, 0), reverse));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_and_line_selections_indent_effective_range_in_both_directions() {
|
||||
for line_mode in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let mut content = Buffer::with_text("one alpha\r\ntwo beta\nthree gamma");
|
||||
content.move_to(Cursor {
|
||||
position: if reverse { at(2, 8) } else { at(0, 6) },
|
||||
selection: None,
|
||||
});
|
||||
content.perform(if line_mode {
|
||||
Action::SelectLine
|
||||
} else {
|
||||
Action::SelectWord
|
||||
});
|
||||
content.perform(Action::Select(if reverse {
|
||||
Motion::DocumentStart
|
||||
} else {
|
||||
Motion::DocumentEnd
|
||||
}));
|
||||
let selected = content.selection().unwrap();
|
||||
apply(&mut content, false);
|
||||
assert_eq!(
|
||||
content.text(),
|
||||
" one alpha\r\n two beta\n three gamma"
|
||||
);
|
||||
let cursor = content.cursor();
|
||||
assert_eq!(
|
||||
cursor.position.line < cursor.selection.unwrap().line,
|
||||
reverse
|
||||
);
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "one alpha\r\ntwo beta\nthree gamma");
|
||||
assert_eq!(content.selection(), Some(selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_outdent_preserves_semantic_word_selection_and_last_empty_line_is_excluded() {
|
||||
let mut content = Buffer::with_text("alpha tail\r\n");
|
||||
content.move_to(Cursor {
|
||||
position: at(0, 2),
|
||||
selection: None,
|
||||
});
|
||||
content.perform(Action::SelectWord);
|
||||
let raw = content.cursor();
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.cursor(), raw);
|
||||
assert_eq!(content.selection().as_deref(), Some("alpha"));
|
||||
content.perform(Action::SelectAll);
|
||||
apply(&mut content, false);
|
||||
assert_eq!(content.text(), " alpha tail\r\n");
|
||||
assert_eq!(content.cursor().position, at(1, 0));
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
//! Effective selected-byte ranges for a committed departure from a source view.
|
||||
//! Effective selected-byte ranges for committed source departures and edits.
|
||||
//!
|
||||
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
|
||||
//! endpoints can therefore save an empty range for a visibly selected word/line.
|
||||
//! Capture only after navigation admission and destination validation succeed,
|
||||
//! immediately before replacing/leaving the live editor. Never use this to inspect
|
||||
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
|
||||
//! Departure capture requires navigation admission and destination validation,
|
||||
//! immediately before replacing/leaving the live editor. Edit capture requires
|
||||
//! an admitted edit with a nonempty patch plan. Neither is a passive query:
|
||||
//! never use these to inspect pending queries, failed/no-op navigation, or restore
|
||||
//! focus to the same editor.
|
||||
use iced::advanced::{graphics, text};
|
||||
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
|
||||
@@ -21,6 +23,23 @@ use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
|
||||
/// Only the selected text is temporarily copied; retained state is one Cursor.
|
||||
pub fn capture<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
effective_range(content)
|
||||
}
|
||||
|
||||
/// Capture immediately before a committed indentation edit. Like departure,
|
||||
/// this converts Word/Line selections to their effective directional byte range.
|
||||
/// The caller restores the transformed range after editing the same Content.
|
||||
pub fn capture_for_edit<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
effective_range(content)
|
||||
}
|
||||
|
||||
fn effective_range<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
|
||||
@@ -391,11 +391,24 @@ fn modal_width<'a>(
|
||||
.into()
|
||||
}
|
||||
fn diagram(app: &App) -> View<'_, Message> {
|
||||
if app
|
||||
.active_diagram
|
||||
.and_then(|id| app.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection.scenario().is_some())
|
||||
{
|
||||
return heading(
|
||||
"Saved Scenario view",
|
||||
crate::app::SAVED_SCENARIO_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
let mut diagrams = app
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||||
.filter(|d| {
|
||||
d.projection.scenario().is_none()
|
||||
&& (d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
.map(|d| Choice {
|
||||
id: d.id,
|
||||
label: d.name.clone(),
|
||||
@@ -924,22 +937,27 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
);
|
||||
}
|
||||
}
|
||||
body = body.push(
|
||||
row![
|
||||
button(text("Apply changes").size(12))
|
||||
.padding([9, 12])
|
||||
.on_press(Message::Apply)
|
||||
.width(Fill),
|
||||
action("Revert", Message::RevertProperties)
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
let actions = row![
|
||||
button(text("Apply changes").size(12))
|
||||
.padding([9, 12])
|
||||
.on_press(Message::Apply)
|
||||
.width(Fill),
|
||||
action("Revert", Message::RevertProperties)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(d) = &app.draft {
|
||||
body = body.push(forms::view(d, p));
|
||||
}
|
||||
container(
|
||||
column![
|
||||
tabs,
|
||||
column![
|
||||
tabs,
|
||||
container(actions).padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
})
|
||||
]
|
||||
.spacing(12),
|
||||
scrollable(body.padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
@@ -1237,6 +1255,15 @@ pub(crate) fn source_binding(
|
||||
use crate::source_navigation::{SymbolEvent, query::Action};
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
use widget::text_editor::{Binding, Status};
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.key == Key::Named(Named::Tab)
|
||||
&& (press.modifiers.is_empty() || press.modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
{
|
||||
return Some(Binding::Custom(Message::SourceIndent(
|
||||
context.clone(),
|
||||
press.modifiers.shift(),
|
||||
)));
|
||||
}
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers.is_empty()
|
||||
&& press.key == Key::Named(Named::F2)
|
||||
|
||||
@@ -13,6 +13,7 @@ use iced::{
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
mod communication;
|
||||
mod counterparts;
|
||||
pub(crate) mod recovery;
|
||||
#[cfg(test)]
|
||||
mod transition_tests;
|
||||
@@ -34,6 +35,7 @@ pub enum Event {
|
||||
Scope(String),
|
||||
Destination(Choice),
|
||||
MappingTarget(Id, Choice),
|
||||
MappingQuery(Id, String),
|
||||
MappingKind(Id, Kind),
|
||||
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
||||
OperationOmission(Id, bool),
|
||||
@@ -274,6 +276,7 @@ pub struct Transition {
|
||||
pub request: TransitionRequest,
|
||||
pub plan: TransitionPlan,
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
counterpart_queries: std::collections::BTreeMap<Id, String>,
|
||||
}
|
||||
impl Transition {
|
||||
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
||||
@@ -352,7 +355,8 @@ impl Transition {
|
||||
}
|
||||
|
||||
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
|
||||
(diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
|
||||
(diagram.projection.is_architecture() || diagram.projection.state_machine().is_some())
|
||||
&& (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
|
||||
&& (diagram.projection.state_machine().is_none() || !targets.is_empty())
|
||||
&& targets.iter().all(|id| {
|
||||
p.elements.get(id).is_some_and(|e| {
|
||||
@@ -515,6 +519,7 @@ impl Workflow {
|
||||
request,
|
||||
plan,
|
||||
communication_ports: Default::default(),
|
||||
counterpart_queries: Default::default(),
|
||||
})))
|
||||
}
|
||||
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
||||
@@ -619,6 +624,12 @@ impl Workflow {
|
||||
_ => {}
|
||||
},
|
||||
Self::Transition(d) => {
|
||||
if let Event::MappingQuery(source, query) = event {
|
||||
d.counterpart_queries.insert(source, query);
|
||||
// Searching is presentation only: preserve reviewed decisions,
|
||||
// preview identities and the exact recovery snapshot.
|
||||
return Ok(None);
|
||||
}
|
||||
match &event {
|
||||
Event::OperationOmission(source, enabled) => {
|
||||
d.request
|
||||
@@ -1148,48 +1159,17 @@ impl Workflow {
|
||||
content=content.push(text("The SA operation remains unchanged. Correlated replies derive this Call's abstraction.").size(11));
|
||||
}
|
||||
let mapping = d.request.mappings.iter().find(|m| m.source == source);
|
||||
let existing = p
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| {
|
||||
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
|
||||
{
|
||||
Some(t.sa)
|
||||
} else if d.request.direction == TransitionDirection::SaToOa
|
||||
&& t.sa == source
|
||||
{
|
||||
Some(t.oa)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
|
||||
if existing.len() == 1 {
|
||||
Some(existing[0])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
let target_choice = counterparts::target(p, source, &d.request);
|
||||
let target = target_choice.flatten();
|
||||
let query = d
|
||||
.counterpart_queries
|
||||
.get(&source)
|
||||
.map_or("", String::as_str);
|
||||
let targets = counterparts::choices(p, source, d.request.direction, query);
|
||||
let no_matches = targets.len() == 1;
|
||||
let selected = target_choice.map(|target| {
|
||||
counterparts::selected(p, source, d.request.direction, target)
|
||||
});
|
||||
let mut targets = vec![Choice {
|
||||
id: Id::nil(),
|
||||
label: "Create new counterpart".into(),
|
||||
}];
|
||||
targets.extend(
|
||||
p.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.filter(|id| {
|
||||
p.layer_of(**id) == Some(d.request.direction.target_layer())
|
||||
&& p.elements.contains_key(&source)
|
||||
== p.elements.contains_key(id)
|
||||
})
|
||||
.map(|id| choice(p, *id)),
|
||||
);
|
||||
let selected = targets
|
||||
.iter()
|
||||
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
|
||||
.cloned();
|
||||
let mut item = column![
|
||||
text(format!(
|
||||
"{} · {}",
|
||||
@@ -1200,14 +1180,29 @@ impl Workflow {
|
||||
.unwrap_or("Relationship")
|
||||
))
|
||||
.size(14),
|
||||
text_input("Find counterpart by name or @alias", query)
|
||||
.on_input(move |query| Message::Workflow(Event::MappingQuery(
|
||||
source, query
|
||||
)))
|
||||
.size(12)
|
||||
.padding(8),
|
||||
pick_list(targets, selected, move |v| Message::Workflow(
|
||||
Event::MappingTarget(source, v)
|
||||
))
|
||||
.placeholder("Choose a counterpart")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(7);
|
||||
if no_matches {
|
||||
let message = if query.trim().is_empty() {
|
||||
"No compatible existing counterparts. Create a new one."
|
||||
} else {
|
||||
"No matching existing counterparts. Clear the search or create a new one."
|
||||
};
|
||||
item = item.push(text(message).size(11));
|
||||
}
|
||||
if target.is_none()
|
||||
&& let Some(e) = p.elements.get(&source)
|
||||
{
|
||||
@@ -1413,16 +1408,6 @@ impl Workflow {
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
|
||||
d.plan.changes.len(),
|
||||
d.plan.conflicts.iter().filter(|c| d.boundary_choice(p, c)).count(),
|
||||
d.plan.conflicts.iter().filter(|c| !d.boundary_choice(p, c)).count(),
|
||||
d.plan.warnings.len()
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
let enabled = match self {
|
||||
@@ -1439,12 +1424,50 @@ impl Workflow {
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
column![
|
||||
scrollable(content.padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
}))
|
||||
.height(480),
|
||||
let mut footer = column![].spacing(8);
|
||||
if let Self::Transition(d) = self {
|
||||
let boundary_choices = d
|
||||
.plan
|
||||
.conflicts
|
||||
.iter()
|
||||
.filter(|c| d.boundary_choice(p, c))
|
||||
.count();
|
||||
footer = footer.push(
|
||||
text(format!(
|
||||
"{} reviewed items · {} boundary choices · {} conflicts · {} warnings",
|
||||
d.plan.changes.len(),
|
||||
boundary_choices,
|
||||
d.plan.conflicts.len() - boundary_choices,
|
||||
d.plan.warnings.len()
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
let guidance = if d.destination_error(p).is_some() {
|
||||
Some("Choose a compatible diagram in Show result in, above the mappings.".into())
|
||||
} else if boundary_choices > 0 {
|
||||
Some("Choose the counterpart kind in each mapping that needs a system boundary decision.".into())
|
||||
} else if let Some(field) = d.plan.conflicts.iter().find_map(|c| c.field) {
|
||||
let field = match field {
|
||||
TransitionField::Name => "Name",
|
||||
TransitionField::Description => "Description",
|
||||
};
|
||||
Some(format!(
|
||||
"Review {field} below the mappings: choose Keep target, Take source, or merged text."
|
||||
))
|
||||
} else if !d.plan.conflicts.is_empty() {
|
||||
Some("Review the conflict messages below the mappings and complete the required decisions.".into())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(guidance) = guidance {
|
||||
footer = footer.push(
|
||||
text(guidance)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
}
|
||||
footer = footer.push(
|
||||
row![
|
||||
Space::new().width(Fill),
|
||||
button(text("Cancel").size(12))
|
||||
@@ -1464,7 +1487,15 @@ impl Workflow {
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
]
|
||||
.spacing(10)
|
||||
.spacing(10),
|
||||
);
|
||||
column![
|
||||
scrollable(content.padding(iced::Padding {
|
||||
right: 14.,
|
||||
..Default::default()
|
||||
}))
|
||||
.height(480),
|
||||
footer
|
||||
]
|
||||
.spacing(18)
|
||||
.into()
|
||||
@@ -1972,3 +2003,37 @@ mod port_authoring_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_scenario_destination_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_transition_results_do_not_make_a_scenario_an_architecture_destination() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Scenario", None)
|
||||
.unwrap();
|
||||
let mut diagram = Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: "target_view".into(),
|
||||
name: "Target".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
};
|
||||
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
||||
.unwrap();
|
||||
diagram.projection = ProjectionKind::StateMachine { machine };
|
||||
assert!(result_view_compatible(&p, &diagram, &[machine], Layer::Oa));
|
||||
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
||||
diagram.projection = ProjectionKind::Overview;
|
||||
assert!(result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
//! Search is separate from the committed counterpart mapping.
|
||||
use super::*;
|
||||
|
||||
/// Outer None requires a choice between distinct traced targets; inner None
|
||||
/// means creation (explicit, or no existing correspondence).
|
||||
pub(super) fn target(p: &Project, source: Id, request: &TransitionRequest) -> Option<Option<Id>> {
|
||||
if let Some(mapping) = request.mappings.iter().find(|m| m.source == source) {
|
||||
return Some(mapping.target);
|
||||
}
|
||||
let existing = p
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|trace| match request.direction {
|
||||
TransitionDirection::OaToSa if trace.oa == source => Some(trace.sa),
|
||||
TransitionDirection::SaToOa if trace.sa == source => Some(trace.oa),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
match existing.len() {
|
||||
0 => Some(None),
|
||||
1 => Some(existing.first().copied()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_choice() -> Choice {
|
||||
Choice {
|
||||
id: Id::nil(),
|
||||
label: "Create new counterpart".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn identity(p: &Project, id: Id) -> Option<(&str, &str, String)> {
|
||||
if let Some(e) = p.elements.get(&id) {
|
||||
Some((&e.name, &e.alias, e.kind().label().into()))
|
||||
} else {
|
||||
p.relations
|
||||
.get(&id)
|
||||
.map(|r| (r.name.as_str(), r.alias.as_str(), r.kind().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
fn label(p: &Project, id: Id) -> Choice {
|
||||
let label = match identity(p, id) {
|
||||
Some((name, alias, kind)) => format!("{} · {kind} · @{alias}", short(name, 48)),
|
||||
None => format!("Missing counterpart · {id}"),
|
||||
};
|
||||
Choice { id, label }
|
||||
}
|
||||
|
||||
pub(super) fn selected(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
direction: TransitionDirection,
|
||||
target: Option<Id>,
|
||||
) -> Choice {
|
||||
let Some(target) = target else {
|
||||
return new_choice();
|
||||
};
|
||||
let mut choice = label(p, target);
|
||||
if p.contains(target) && !counterpart_compatible(p, source, target, direction) {
|
||||
choice.label.push_str(" (incompatible)");
|
||||
}
|
||||
choice
|
||||
}
|
||||
|
||||
pub(super) fn choices(
|
||||
p: &Project,
|
||||
source: Id,
|
||||
direction: TransitionDirection,
|
||||
query: &str,
|
||||
) -> Vec<Choice> {
|
||||
let query = query.trim().to_lowercase();
|
||||
let mut candidates = p
|
||||
.elements
|
||||
.keys()
|
||||
.chain(p.relations.keys())
|
||||
.copied()
|
||||
.filter(|id| counterpart_compatible(p, source, *id, direction))
|
||||
.filter_map(|id| {
|
||||
let (name, alias, _) = identity(p, id)?;
|
||||
let name = name.to_lowercase();
|
||||
let alias = alias.to_lowercase();
|
||||
let matches = if let Some(query) = query.strip_prefix('@') {
|
||||
alias.contains(query)
|
||||
} else {
|
||||
name.contains(&query) || alias.contains(&query)
|
||||
};
|
||||
matches.then_some((name, alias, id))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
candidates.sort();
|
||||
std::iter::once(new_choice())
|
||||
.chain(candidates.into_iter().map(|(_, _, id)| label(p, id)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
let first = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
let second = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
let incompatible = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Assess pressure", None)
|
||||
.unwrap();
|
||||
p.elements.get_mut(&first).unwrap().alias = "alpha_assess".into();
|
||||
p.elements.get_mut(&second).unwrap().alias = "beta_assess".into();
|
||||
(p, source, first, second, incompatible)
|
||||
}
|
||||
|
||||
fn ids(choices: Vec<Choice>) -> Vec<Id> {
|
||||
choices.into_iter().map(|c| c.id).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_disambiguates_names_with_aliases_and_retains_creation() {
|
||||
let (p, source, first, second, _) = fixture();
|
||||
let all = choices(&p, source, TransitionDirection::OaToSa, "");
|
||||
assert_eq!(
|
||||
all.iter().map(|c| c.id).collect::<Vec<_>>(),
|
||||
vec![Id::nil(), first, second]
|
||||
);
|
||||
assert!(all[1].label.contains("@alpha_assess"));
|
||||
assert!(all[2].label.contains("@beta_assess"));
|
||||
assert_ne!(all[1].label, all[2].label);
|
||||
for (query, expected) in [
|
||||
(" ASSESS PRESSURE ", vec![Id::nil(), first, second]),
|
||||
(" @BETA_ ", vec![Id::nil(), second]),
|
||||
("ALPHA", vec![Id::nil(), first]),
|
||||
("@pressure", vec![Id::nil()]),
|
||||
("no match", vec![Id::nil()]),
|
||||
] {
|
||||
assert_eq!(
|
||||
ids(choices(&p, source, TransitionDirection::OaToSa, query)),
|
||||
expected,
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
ids(choices(&p, first, TransitionDirection::SaToOa, "PRESSURE")),
|
||||
vec![Id::nil(), source]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_preserves_reviewed_request_plan_ids_and_recovery_exactly() {
|
||||
let (mut p, source, first, _, _) = fixture();
|
||||
let second = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Second source card", None)
|
||||
.unwrap();
|
||||
let before_project = p.clone();
|
||||
// Exercise both an existing reviewed target and generated preview IDs.
|
||||
for chosen in [Some(first), None] {
|
||||
let mut w = Workflow::transition(&p, source).unwrap();
|
||||
let Workflow::Transition(t) = &mut w else {
|
||||
panic!()
|
||||
};
|
||||
t.request.sources.push(second);
|
||||
t.plan = plan_transition(&p, &t.request).unwrap();
|
||||
w.update(
|
||||
&p,
|
||||
Event::MappingTarget(
|
||||
source,
|
||||
selected(&p, source, TransitionDirection::OaToSa, chosen),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
w.update(
|
||||
&p,
|
||||
Event::Resolve(
|
||||
source,
|
||||
TransitionField::Name,
|
||||
FieldDecision::Value("Reviewed name".into()),
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let saved = w.recovery_snapshot().unwrap();
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
let plan = t.plan.clone();
|
||||
let request = t.request.clone();
|
||||
for query in ["assess", "@beta", "no match", ""] {
|
||||
assert!(
|
||||
w.update(&p, Event::MappingQuery(source, query.into()))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(w.recovery_snapshot().unwrap(), saved);
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(t.plan, plan);
|
||||
assert_eq!(t.request, request);
|
||||
assert_eq!(t.counterpart_queries[&source], query);
|
||||
assert_eq!(target(&p, source, &t.request), Some(chosen));
|
||||
assert_eq!(
|
||||
selected(&p, source, t.request.direction, chosen).id,
|
||||
chosen.unwrap_or(Id::nil())
|
||||
);
|
||||
}
|
||||
w.update(&p, Event::MappingQuery(second, "other card".into()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(t) = &w else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(t.counterpart_queries[&source], "");
|
||||
assert_eq!(t.counterpart_queries[&second], "other card");
|
||||
assert_eq!(t.plan, plan);
|
||||
let restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
let Workflow::Transition(t) = restored else {
|
||||
panic!()
|
||||
};
|
||||
assert!(t.counterpart_queries.is_empty());
|
||||
assert_eq!(t.request, request);
|
||||
}
|
||||
assert_eq!(p, before_project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_invalid_or_missing_mapping_stays_visible_and_refuses_apply() {
|
||||
let (p, source, _, _, incompatible) = fixture();
|
||||
for chosen in [incompatible, Id::from_u128(987654321)] {
|
||||
let mut w = Workflow::transition(&p, source).unwrap();
|
||||
w.update(
|
||||
&p,
|
||||
Event::MappingTarget(
|
||||
source,
|
||||
Choice {
|
||||
id: chosen,
|
||||
label: "Recovered choice".into(),
|
||||
},
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let saved = w.recovery_snapshot().unwrap();
|
||||
let mut restored = Workflow::from_recovery(&p, saved.clone()).unwrap();
|
||||
restored
|
||||
.update(&p, Event::MappingQuery(source, "no match".into()))
|
||||
.unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
let Workflow::Transition(t) = &restored else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(target(&p, source, &t.request), Some(Some(chosen)));
|
||||
let display = selected(&p, source, t.request.direction, Some(chosen));
|
||||
assert_eq!(display.id, chosen);
|
||||
assert!(display.label.contains(if chosen == incompatible {
|
||||
"incompatible"
|
||||
} else {
|
||||
"Missing counterpart"
|
||||
}));
|
||||
assert!(
|
||||
!choices(&p, source, t.request.direction, "")
|
||||
.iter()
|
||||
.any(|c| c.id == chosen)
|
||||
);
|
||||
assert!(restored.update(&p, Event::Apply).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_inference_counts_distinct_targets_and_explicit_mapping_wins() {
|
||||
let (mut p, source, first, second, _) = fixture();
|
||||
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
|
||||
assert_eq!(target(&p, source, &request), Some(None));
|
||||
for (i, counterpart) in [first, first, second].into_iter().enumerate() {
|
||||
let id = Id::from_u128(100 + i as u128);
|
||||
p.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: format!("trace_{i}"),
|
||||
name: format!("Trace {i}"),
|
||||
description: String::new(),
|
||||
oa: source,
|
||||
sa: counterpart,
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
target(&p, source, &request),
|
||||
if i < 2 { Some(Some(first)) } else { None }
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
target(
|
||||
&p,
|
||||
first,
|
||||
&TransitionRequest::new(TransitionDirection::SaToOa, vec![first])
|
||||
),
|
||||
Some(Some(source))
|
||||
);
|
||||
for chosen in [Some(second), None] {
|
||||
request.mappings = vec![TransitionMapping {
|
||||
source,
|
||||
target: chosen,
|
||||
target_kind: None,
|
||||
}];
|
||||
assert_eq!(target(&p, source, &request), Some(chosen));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ impl Workflow {
|
||||
request,
|
||||
plan,
|
||||
communication_ports,
|
||||
counterpart_queries: Default::default(),
|
||||
};
|
||||
transition.sync_communication_ports();
|
||||
transition.plan =
|
||||
|
||||
@@ -13,6 +13,7 @@ fn view(p: &mut Project, number: u128, projection: ProjectionKind) -> Id {
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,6 +16,8 @@ mod data_contracts;
|
||||
mod edge_attachments;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/scenario_layout.rs"]
|
||||
mod scenario_layout;
|
||||
#[path = "http/semantic_transitions.rs"]
|
||||
mod semantic_transitions;
|
||||
#[path = "http/source6_views.rs"]
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Saved Scenario geometry uses the same atomic model/source HTTP transaction.
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, MessageKind, OccurrenceBody, OccurrenceEndpoint, ProjectionKind,
|
||||
ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition, ScenarioViewpoint, SharedBody,
|
||||
source_project::{ProjectLimits, SourceBundle, compile_bundle, format_bundle},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
bundle: SourceBundle,
|
||||
views: [Diagram; 2],
|
||||
lane: Id,
|
||||
messages: [Id; 3],
|
||||
foreign: Id,
|
||||
}
|
||||
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut model = Project::empty();
|
||||
let owner = model
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
|
||||
.unwrap();
|
||||
let other = model
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Other delivery", None)
|
||||
.unwrap();
|
||||
for id in [owner, other] {
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
model.elements.get_mut(&id).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Scenario body")
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
}
|
||||
let sender = model
|
||||
.add_element(Kind::Entity, Layer::Oa, "Sender", None)
|
||||
.unwrap();
|
||||
let receiver = model
|
||||
.add_element(Kind::Entity, Layer::Oa, "Receiver", None)
|
||||
.unwrap();
|
||||
let lane = model
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Sender lane", Some(owner))
|
||||
.unwrap();
|
||||
let destination = model
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Receiver lane", Some(owner))
|
||||
.unwrap();
|
||||
for (id, represented) in [(lane, sender), (destination, receiver)] {
|
||||
*model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let exchange = model.relate(
|
||||
RelationKind::CommunicationMean,
|
||||
"Delivery channel",
|
||||
sender,
|
||||
receiver,
|
||||
);
|
||||
let mut occurrences = Vec::new();
|
||||
for (name, parent, complete) in [
|
||||
("First delivery", owner, true),
|
||||
("Repeated delivery", owner, true),
|
||||
("Unfinished delivery", owner, false),
|
||||
("Foreign delivery", other, false),
|
||||
] {
|
||||
let id = model
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(parent))
|
||||
.unwrap();
|
||||
*model.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: complete.then_some(exchange),
|
||||
sender: complete.then_some(lane),
|
||||
receiver: complete.then_some(destination),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
occurrences.push(id);
|
||||
}
|
||||
*model.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = occurrences[..3].to_vec();
|
||||
*model.elements.get_mut(&other).unwrap().steps_mut().unwrap() = vec![occurrences[3]];
|
||||
let views = ["delivery_wide", "delivery_review"].map(|alias| 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,
|
||||
});
|
||||
let mut bundle = format_bundle(&model);
|
||||
let entry = bundle.files.get_mut(&bundle.entry).unwrap();
|
||||
*entry = format!(
|
||||
"// Transport fixture rationale\n{}",
|
||||
entry.replacen("{\n", "{\n include \"notes.arcadia\";\n", 1)
|
||||
);
|
||||
bundle.files.insert(
|
||||
"notes.arcadia".into(),
|
||||
"// Unrelated rationale: 航海\r\n".into(),
|
||||
);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, model);
|
||||
assert!(
|
||||
oa_core::validation::validate(&model)
|
||||
.iter()
|
||||
.all(|issue| issue.severity != oa_core::validation::Severity::Error)
|
||||
);
|
||||
Fixture {
|
||||
bundle: compiled.bundle,
|
||||
views,
|
||||
lane,
|
||||
messages: [occurrences[0], occurrences[1], occurrences[2]],
|
||||
foreign: occurrences[3],
|
||||
}
|
||||
}
|
||||
|
||||
async fn post(
|
||||
server: &TestServer,
|
||||
current: &StoredProject,
|
||||
etag: &str,
|
||||
commands: Value,
|
||||
dry_run: bool,
|
||||
) -> (StatusCode, Option<String>, Value) {
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
||||
.header("if-match", etag)
|
||||
.json(&json!({"commands": commands, "dry_run": dry_run}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let etag = response
|
||||
.headers()
|
||||
.get("etag")
|
||||
.map(|value| value.to_str().unwrap().to_owned());
|
||||
(status, etag, response.json().await.unwrap())
|
||||
}
|
||||
|
||||
async fn assert_unchanged(server: &TestServer, current: &StoredProject, disk: &Path, bytes: &[u8]) {
|
||||
assert_eq!(
|
||||
serde_json::to_value(server.get(current.resource_id).await).unwrap(),
|
||||
serde_json::to_value(current).unwrap(),
|
||||
"entire stored envelope, including Project, source and revision"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(disk).unwrap(),
|
||||
bytes,
|
||||
"physical stored envelope must not be rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_source_pair(current: &StoredProject, notes: &str) {
|
||||
let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle, current.source_project);
|
||||
assert_eq!(compiled.decoded.project, current.model);
|
||||
assert_eq!(current.source_project.files["notes.arcadia"], notes);
|
||||
assert!(
|
||||
current.source_project.files[¤t.source_project.entry]
|
||||
.starts_with("// Transport fixture rationale\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_scenario_positions_roundtrip_through_http_without_partial_or_noop_writes() {
|
||||
let f = fixture();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&f.bundle)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let summary: Value = response.json().await.unwrap();
|
||||
let id = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let original = server.get(id).await;
|
||||
assert_eq!(original.source_project, f.bundle);
|
||||
let disk = directory.path().join(format!("{id}.json"));
|
||||
let original_bytes = std::fs::read(&disk).unwrap();
|
||||
let [wide, review] = f.views.clone();
|
||||
// Raw JSON exercises the actual externally-tagged transport fields. Two
|
||||
// messages deliberately share one engineering exchange but retain distinct IDs.
|
||||
let commands = json!([
|
||||
{"CreateDiagram": wide}, {"CreateDiagram": review},
|
||||
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}},
|
||||
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 200.0}}},
|
||||
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[1]}, "send_y": 275.0}}},
|
||||
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}},
|
||||
{"SetScenarioLanePosition": {"diagram": review.id, "position": {"lifeline": f.lane, "x": 760.0}}},
|
||||
{"SetScenarioMessagePosition": {"diagram": review.id, "position": {"address": {"references": [], "occurrence": f.messages[0]}, "send_y": 430.0}}}
|
||||
]);
|
||||
// Independent expected Project: populate the specified records directly;
|
||||
// do not use execute_batch/prepare_command as the success oracle.
|
||||
let mut expected = original.model.clone();
|
||||
let mut expected_wide = wide.clone();
|
||||
expected_wide.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: -120.0,
|
||||
}],
|
||||
messages: f
|
||||
.messages
|
||||
.into_iter()
|
||||
.zip([200., 275., -35.])
|
||||
.map(|(id, send_y)| ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}));
|
||||
let mut expected_review = review.clone();
|
||||
expected_review.layout = Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 760.0,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[0]),
|
||||
send_y: 430.0,
|
||||
}],
|
||||
}));
|
||||
expected.diagrams.insert(wide.id, expected_wide);
|
||||
expected.diagrams.insert(review.id, expected_review.clone());
|
||||
let (status, etag, preview) =
|
||||
post(&server, &original, &original.etag(), commands.clone(), true).await;
|
||||
assert_eq!(status, StatusCode::OK, "{preview}");
|
||||
assert_eq!(etag, Some(original.etag()));
|
||||
assert_eq!(preview["revision"], original.revision);
|
||||
assert_eq!(preview["changed"], true);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let preview_source: SourceBundle =
|
||||
serde_json::from_value(preview["source_project"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
compile_bundle(&preview_source, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
assert_unchanged(&server, &original, &disk, &original_bytes).await;
|
||||
let (status, etag, committed) =
|
||||
post(&server, &original, &original.etag(), commands, false).await;
|
||||
assert_eq!(status, StatusCode::OK, "{committed}");
|
||||
assert_eq!(committed["changed"], true);
|
||||
let current = server.get(id).await;
|
||||
assert_eq!(etag, Some(current.etag()));
|
||||
assert_eq!(current.revision, original.revision + 1);
|
||||
assert_eq!(current.model, expected);
|
||||
assert_eq!(
|
||||
current.source_project, preview_source,
|
||||
"preview and commit must agree exactly"
|
||||
);
|
||||
assert_source_pair(¤t, &f.bundle.files["notes.arcadia"]);
|
||||
let current_bytes = std::fs::read(&disk).unwrap();
|
||||
let no_op = json!([
|
||||
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": -120.0}}},
|
||||
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.messages[2]}, "send_y": -35.0}}}
|
||||
]);
|
||||
for dry_run in [true, false] {
|
||||
let (status, etag, result) =
|
||||
post(&server, ¤t, ¤t.etag(), no_op.clone(), dry_run).await;
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(etag, Some(current.etag()));
|
||||
assert_eq!(result["changed"], false);
|
||||
assert_eq!(result["revision"], current.revision);
|
||||
assert_unchanged(&server, ¤t, &disk, ¤t_bytes).await;
|
||||
}
|
||||
// A stale request must fail even when its coordinate value is a no-op.
|
||||
let (status, _, result) = post(&server, ¤t, &original.etag(), no_op, false).await;
|
||||
assert_eq!(status, StatusCode::PRECONDITION_FAILED, "{result}");
|
||||
assert_eq!(result["code"], "revision_conflict");
|
||||
assert_unchanged(&server, ¤t, &disk, ¤t_bytes).await;
|
||||
// First command is valid. The second belongs to another Scenario; neither
|
||||
// a dry-run nor a real transaction may expose/persist the partial candidate.
|
||||
let invalid = json!([
|
||||
{"SetScenarioLanePosition": {"diagram": wide.id, "position": {"lifeline": f.lane, "x": 999.0}}},
|
||||
{"SetScenarioMessagePosition": {"diagram": wide.id, "position": {"address": {"references": [], "occurrence": f.foreign}, "send_y": 400.0}}}
|
||||
]);
|
||||
for dry_run in [true, false] {
|
||||
let (status, _, result) =
|
||||
post(&server, ¤t, ¤t.etag(), invalid.clone(), dry_run).await;
|
||||
assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{result}");
|
||||
assert_eq!(result["code"], "command_rejected");
|
||||
assert!(result.get("model").is_none());
|
||||
assert_unchanged(&server, ¤t, &disk, ¤t_bytes).await;
|
||||
}
|
||||
let mut removals =
|
||||
vec![json!({"RemoveScenarioLanePosition": {"diagram": wide.id, "lifeline": f.lane}})];
|
||||
removals.extend(f.messages.map(|occurrence| json!({"RemoveScenarioMessagePosition": {"diagram": wide.id, "address": {"references": [], "occurrence": occurrence}}})));
|
||||
let removals = Value::Array(removals);
|
||||
let (status, _, result) =
|
||||
post(&server, ¤t, ¤t.etag(), removals.clone(), false).await;
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(result["changed"], true);
|
||||
let removed = server.get(id).await;
|
||||
assert_eq!(removed.revision, current.revision + 1);
|
||||
expected.diagrams.get_mut(&wide.id).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
assert_eq!(
|
||||
removed.model, expected,
|
||||
"removal changes only the addressed saved view"
|
||||
);
|
||||
assert_eq!(removed.model.diagrams[&review.id], expected_review);
|
||||
assert_source_pair(&removed, &f.bundle.files["notes.arcadia"]);
|
||||
let removed_bytes = std::fs::read(&disk).unwrap();
|
||||
let (status, _, result) = post(&server, &removed, &removed.etag(), removals, false).await;
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(result["changed"], false);
|
||||
assert_unchanged(&server, &removed, &disk, &removed_bytes).await;
|
||||
let exported = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{id}/source-project")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(exported.status(), StatusCode::OK);
|
||||
assert_eq!(exported.headers()["etag"].to_str().unwrap(), removed.etag());
|
||||
assert_eq!(
|
||||
exported.json::<SourceBundle>().await.unwrap(),
|
||||
removed.source_project
|
||||
);
|
||||
server.stop().await;
|
||||
let restarted = TestServer::start(directory.path(), None).await;
|
||||
assert_unchanged(&restarted, &removed, &disk, &removed_bytes).await;
|
||||
restarted.stop().await;
|
||||
}
|
||||
@@ -49,6 +49,8 @@ P1 extension to frozen relation interface: FunctionalExchange now has `source_po
|
||||
|
||||
`transitions::counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Kind>` is the shared choice policy for UI and other clients. It returns supported explicit element counterpart kinds in stable `Kind::ALL` order using the same compatibility predicate as transition validation; an empty list means no automatic element mapping.
|
||||
|
||||
`transitions::counterpart_compatible(&Project, source: Id, target: Id, direction: TransitionDirection) -> bool` checks existing-record candidate eligibility: both IDs must exist in the direction's respective layers, belong to the same element/relation category, and have compatible transition kinds. It does not approve properties, dependencies or structural changes; clients still preview and apply a complete plan. Manual trace creation retains its separate policy. The desktop counterpart search uses this predicate, filters by name or `@alias`, and sorts by name then alias and ID. Search is presentation state: it does not replan, change mappings or reviewed field decisions, or enter workflow recovery data. A selected target remains visible when filtered out; invalid or missing recovered targets remain visible with their error and require correction. Multiple traces to the same target count as one inferred counterpart; distinct traced targets require an explicit choice.
|
||||
|
||||
`TransitionRequest {direction:OaToSa|SaToOa, sources:Vec<Id>, scope:SelectedOnly|Required|Subtree, mappings:Vec<TransitionMapping>, fields:Vec<TransitionField>, resolutions:Vec<FieldResolution>, destination_diagram:Option<Id>}`. `TransitionRequest::new(direction,sources)` defaults to selected-only and name/description fields. `TransitionMapping {source,target:Option<Id>,target_kind:Option<Kind>}` selects reuse or explicit creation kind. An explicit mapping with target=None means create a new counterpart even if another trace exists. Without mapping, one existing trace is reused; multiple traces require selection.
|
||||
|
||||
`FieldResolution {source,field:Name|Description,decision:TakeSource|KeepTarget|Value(String)}`. `TransitionPlan {base_fingerprint,request,changes,conflicts,warnings,commands}`. Changes carry source/target IDs, Create/Update/Reuse and before/after field changes. Conflicts have optional source/target/field and message. Conflicts prevent apply. The deterministic fingerprint guards stale plans, is not a security hash, and does not introduce persistent project revision fields. Planned IDs and aliases remain unchanged during apply. Server owns request idempotency/revision storage. Apply is one candidate transaction; Session adoption can use Command::Replace(candidate) for one Undo.
|
||||
|
||||
@@ -151,7 +151,7 @@ fn diagram(
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram_id, Diagram { id: diagram_id, alias: alias.into(), name: name.into(), description: "Teaching view: positions and filters do not change ownership or exchange identities.".into(), layer, projection, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
p.diagrams.insert(diagram_id, Diagram { id: diagram_id, alias: alias.into(), name: name.into(), description: "Teaching view: positions and filters do not change ownership or exchange identities.".into(), layer, projection, view_options: ViewOptions::default(), layout: None, nodes, routes: vec![] });
|
||||
diagram_id
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Architectural and product decisions
|
||||
|
||||
## Private source composition decision — 2026-09-23
|
||||
|
||||
Adopt the opaque validated original-span rename batch as the next private
|
||||
analysis adapter step, with the [independent review's clarifications](analysis-original-span-review-20260923.md).
|
||||
The [original proposal](analysis-original-span-proposal-20260922.md) compares
|
||||
this against replacing both ordinary and typed edit representations. The narrow
|
||||
adapter preserves current ordinary compatibility and reduces migration risk;
|
||||
a shared edit AST remains a candidate if structural composition demonstrates a
|
||||
need. Neither sequentially applying producers with stale offsets nor canonical
|
||||
rewriting is acceptable for authored-source preservation.
|
||||
|
||||
The immediate patch exports complete borrowed rename records and splits
|
||||
ownership validation from standalone final-output size preflight. It may defer
|
||||
only hypothetical typed-only output sizing; active inherited budget, plan/name
|
||||
storage, qualified namespace, source equality and complete occurrence checks
|
||||
remain mandatory. Standalone rename/apply semantics remain intact. This is
|
||||
private staging, not a complete parse-session or public source8 boundary.
|
||||
|
||||
Before production composition, one checked parsed-bundle witness must bind the
|
||||
complete source/entry/file map, ordinary model, typed index, profile and complete
|
||||
target intent. Ordinary syntax-kind replacement currently emits same-start
|
||||
insertion/deletion pairs; explicitly normalize such owned operations with exact
|
||||
trivia oracles before admitting them, keeping cross-producer collisions errors.
|
||||
Gate supported changes by a precise complete-model diff. Compile the actual
|
||||
emitted full bundle, use normal admission, and compare the complete intended
|
||||
Project before installation. Exact untouched bytes are an independent gate.
|
||||
Subtree edits, parameter lifecycle, repaired deletion, reidentity, relocation,
|
||||
missing-ID stabilization, migration/history and checked-save remain required
|
||||
delivery work; passing the private rename slice cannot close those requirements.
|
||||
|
||||
The source-pinned proposal/review retain their original local provenance labels.
|
||||
The accepted underlying repair contract is [r2 at analysis source 1b1c3e3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/1b1c3e3abb6fc3d0934c9dbfbd21fddc8ae4f594/harness/analysis-source8-20260913/analysis-source8-repair-design-r2-20260913.md).
|
||||
|
||||
## Current continuation decisions — 2026-09-06
|
||||
|
||||
- Saving under another source entry basename is destination bookkeeping, not an engineering edit. Adopt an exact current save receipt without adding Undo or clearing Redo; rebase uniform entry names across retained history after collision and compilation checks. Stale receipts cannot redirect newer raw source. Native discovery and eleven focused regression results are in release-save-as-runtime-packaging.md; updated native verification remains required.
|
||||
|
||||
+79
-14
@@ -1,4 +1,4 @@
|
||||
# Verified branch checkpoint — 2026-09-13 UTC
|
||||
# Verified branch checkpoint — 2026-09-23 UTC
|
||||
|
||||
This describes Codex-956atf's contribution, not a combined release.
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative. The complete OA/SA
|
||||
@@ -11,10 +11,55 @@ active. The VPN remains untouched.
|
||||
## Current integrated candidate and evidence
|
||||
|
||||
Branch `codex/behavior-authoring-20260910`, published as `codex/release1-956atf`.
|
||||
Current source **c28abcf13a834585a9954f7e54175d47c0a0460b** adds Find focus and
|
||||
departure-selection corrections. Combined execution and native acceptance are
|
||||
not yet established; [integration issue27](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/27)
|
||||
holds current verification and release coordination.
|
||||
Integrated code checkpoint **4ffff0d9d94bd1875e6b316b84ac11e5c5c9657c** combines
|
||||
the reviewed ordinary/Text contribution **3f9e6a18af96b5cd10940da4093be8da28b9c835**
|
||||
with source-editor Tab/Shift+Tab indentation. Windows desktop verification passes
|
||||
1,188 tests with six existing ignores; all 1,194 names and nine targeted
|
||||
indentation cases are accounted for. Strict desktop Clippy and matching GUI
|
||||
build pass on 711 unchanged inputs. The closed native trials cover caret and
|
||||
forward/reverse block indentation, text undo/redo, empty drafts, continued caret
|
||||
typing and Apply/Save/reopen. The saved 577 bytes exactly preserve the baseline's
|
||||
mixed line endings and Unicode while indenting its 13 physical lines. The first
|
||||
trial restored the baseline and is not a save oracle. Find's Tab does not advance
|
||||
focus; this inherited limitation is distinct from the verified editor boundary.
|
||||
This is Windows contribution acceptance, not complete editor accessibility,
|
||||
public source8, a combined peer union, or a packaged release.
|
||||
[Exact gates, original native captures and independent review](experiments/text-indentation-4ffff0d-20260923/README.md)
|
||||
retain both the failed initial compilation and the interrupted first trial.
|
||||
|
||||
Earlier code checkpoint **8abc144cf233c64e43c6bee5033c22ee818e2d14** preserves
|
||||
the production inputs of **feab2020d8e5b6bfdb59c86ab4aee161617b8b6f**. Its Windows
|
||||
desktop suite passes 1,096 tests with three existing manual ignores; all 1,099
|
||||
names are admitted. Targeted navigation/rename checks, strict desktop Clippy and
|
||||
the matching development GUI build pass. The closed native trial verifies
|
||||
Details-to-Preview scroll restoration, explicit reset boundaries, and exact
|
||||
six-file preservation after Cancel and Save. The
|
||||
[portable evidence](experiments/rename-scroll-feab202-20260913/README.md), published
|
||||
at **f95d0310482565e91220a97e13fc756f27f9d590**, retains the initial failing test,
|
||||
20 original frames and independent review. Its committed bytes and verifier were
|
||||
rechecked on September 22. This narrow trial does not establish Text integration,
|
||||
complete rename acceptance or release readiness.
|
||||
|
||||
Earlier production source follows; each result retains its original code scope.
|
||||
Verified production source **44095f78c28b79d6d085863dc928adba8e24d955** includes
|
||||
Find focus, departure-selection and scoped Escape corrections. Its Windows
|
||||
desktop suite passes1095 with3 existing manual ignores; core passes471 with1
|
||||
manual ignore; server passes56. All three strict Clippy scopes and the matching
|
||||
development GUI/server build pass. [Exact source, commands and raw evidence](release-navigation-440-windows-20260913.md).
|
||||
The integrated documentation checkpoint **3c22fe2629fc242714d32805790262fa2c4e33b9**
|
||||
preserves those production inputs and includes the updated AGENTS instructions.
|
||||
|
||||
The actual440 native trial closed normally with79 notes,78 images and14 complete
|
||||
saved-model/source comparisons. Rename/Undo/Redo/reopen, scoped selection,
|
||||
placement/state targeting and sampled OA/SA lifecycle/topology navigation are
|
||||
verified within the [closed trial's explicit scope](experiments/navigation-native-440-20260913/REPORT.md).
|
||||
It found a same-page Details-to-Preview scroll-loss defect; initial keyboard
|
||||
Delete after chooser navigation also required an explicit canvas click. Neither
|
||||
is hidden by passing saved-byte checks. The scroll defect is corrected and
|
||||
verified by the separate feab trial above; the initial chooser-to-canvas keyboard
|
||||
focus friction remains a limitation of the 440 trial. This remains a development
|
||||
checkpoint, not a combined release. [Integration issue27](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/27)
|
||||
owns current verification, correction and release coordination.
|
||||
Earlier source **e1240295bd114d23d3f67a69e6b38bdb2e25bb0d** combines the canvas
|
||||
contribution with source/diagram navigation and reviewed alias rename. Its1082-test
|
||||
listing,17 cross-feature tests,4 mounted preview-scroll tests and strict all-target
|
||||
@@ -70,16 +115,36 @@ failures and corrections. Earlier authoring and allocation checkpoints remain in
|
||||
|
||||
## Isolated contributions and material limitations
|
||||
|
||||
Analysis/core source checkpoint **b2c970b4673e062e47eadd12906d2316c4b1a7b3**,
|
||||
Analysis/core source checkpoint **a2a28f8d23a46ba1696b6a8410d4d3b5c1808f49**,
|
||||
publication branch `codex/analysis-core-956atf-20260911`, is not integrated into
|
||||
production. It adds source-save decode/equality containment and a private staged
|
||||
expression/declaration parser, then repairs the frozen source3 lowering envelope.
|
||||
Its129-test library run has113 passes and16 failures, all at the unsupported
|
||||
source8 header. All22 expression/declaration/token cases and8 bridge/save guards
|
||||
pass; strict all-target Clippy passes on524 unchanged inputs. The separate language
|
||||
integration target has7 passes and9 failures:8 unsupported headers and an old V2
|
||||
oracle that omits the new empty definitions catalog. [Exact S2a evidence](release-analysis-source-s2a-20260913.md)
|
||||
preserves every failure and the independent structural comparison.
|
||||
production. Its private staged typed parser/lowerer has142 listed tests,
|
||||
34 passing typed cases,1 passing raw-token case and strict all-target core Clippy
|
||||
on531 unchanged inputs. The complete library run remains126 passed/16 failed,
|
||||
with all16 failures at the unsupported public source8 header. The original
|
||||
fixture error and its test-only correction remain in [exact S2b evidence](release-analysis-source-s2b-20260913.md).
|
||||
Published private writer **f1d79df4979121335ed7b4ce4da84b2fca8a9ef4** has
|
||||
[portable S2c evidence](experiments/analysis-source-s2c-windows-20260913/README.md):
|
||||
152 listed, 44 typed and one token test passed, strict Clippy passed; the full
|
||||
library result remains 136 passed / 16 failed at unsupported public source8.
|
||||
Published source **1b1c3e3abb6fc3d0934c9dbfbd21fddc8ae4f594** adds a private
|
||||
qualified source index/rename plan and the independently reviewed cleanup for
|
||||
the demonstrated depth129 rejected-tree stack overflow. Its
|
||||
[S3 evidence](experiments/analysis-source-s3-windows-20260922/README.md), published
|
||||
at **345a42e4658f6e0e8e01ef64a2df8f4e28a8945a**, records 167 listed tests,
|
||||
59 typed passes (nine index/five update), one token pass, three clean stack
|
||||
controls and strict Clippy on 538 unchanged inputs. The original overflow and
|
||||
dirty diagnostic/probe results are retained. No current full-core pass is
|
||||
claimed. Public source8 parser, writer, source-update and persistence composition
|
||||
remain incomplete; these branches are not integrated production analysis.
|
||||
|
||||
The [reviewed S1/S7 assessment](issue28-s1-s7-assessment-956atf-20260923.md)
|
||||
distinguishes static analysis/case overlays from contextual inheritance,
|
||||
fixed/default bindings and executable trade studies with durable result
|
||||
provenance. Strengthened fixture and parity criteria remain proposals, not
|
||||
implemented evaluation or a replacement metamodel decision.
|
||||
Earlier [S2a evidence](release-analysis-source-s2a-20260913.md) retains its
|
||||
129-test113/16 library result and separate7/9 language integration result,
|
||||
including the old V2 oracle that omitted the empty definitions catalog.
|
||||
[Earlier bridge evidence](release-analysis-source3-bridge-20260913.md) remains scoped
|
||||
to6216cff.
|
||||
The earlier2c515cf full library run had98 passes and16 failures:14 rejected the
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# S3 original-span composition proposal
|
||||
|
||||
**Proposal, not an accepted decision or implementation.** Read-only source inspection on 2026-09-22: private analysis `1b1c3e3abb6fc3d0934c9dbfbd21fddc8ae4f594` in `.local/ai3`; ordinary updater at root `4ffff0d9d94bd1875e6b316b84ac11e5c5c9657c`. No compiler, source mutation, public activation, metamodel change or critic delegation. Parent owns adoption/integration.
|
||||
|
||||
Requirements come from `harness/analysis-source8-20260913/analysis-source8-repair-design-r2-20260913.md`, “Source pipeline and exact editing,” and the S3/S2 handoffs `.local/analysis-source8-s3-index-handoff-20260913.md` / `.local/analysis-source8-s2-implementation-plan-20260913.md`. Earlier plans describe intended interfaces; the implementation facts below take precedence for what currently exists.
|
||||
|
||||
## Observed seams and smallest useful scope
|
||||
|
||||
Private `analysis_source/update.rs` already retains qualified occurrence keys and original locations, validates complete simultaneous final namespaces, exact original numeric-file map/bytes, token spelling, complete required edit membership and overlap. It exports only whole-file `Plan::apply`. `Index::parse` consumes typed-only files; `Index::build` is private and trusts its own lowerer's occurrence completeness. `Writer::expression(owner, field, expr, Parent)` exists for later subtree changes; a rename needs no writer invocation.
|
||||
|
||||
Root `source_project.rs` already collects ordinary original-coordinate `Edits = BTreeMap<LogicalPath, Vec<(Span, String)>>` before `apply_edits` (lines271,679,1050). `SyntaxUpdate` uses complete old/new comparison values plus separate compact insertion values; preserve that distinction. Project-header slices explicitly add the header's file offset once. Current `apply_edits` groups identical spans by concatenating text, including nonempty replacements, and validates/mutates one file at a time. That is not a sufficient mixed-producer validation boundary.
|
||||
|
||||
After scalar application, `source_structure::update` can reparse and relocate pieces using updated coordinates; it returns immediately when no structural change is needed. Therefore the first integrated slice should explicitly cover stable-ID, topology-preserving global/scoped renames and ordinary scalar edits with disjoint ownership. Reject/defer additions, deletions, moves, reidentity and typed subtree changes at this slice's entry. The existing public updater keeps its current broader behavior until those operations have a compatible composition design; do not claim that its structural second phase already uses the new single-application protocol.
|
||||
|
||||
## Two candidates
|
||||
|
||||
| Candidate | Preservation and ownership | Cost / limitation |
|
||||
| --- | --- | --- |
|
||||
| A. Opaque validated typed edit batch + bundle-owned compositor | Keep Index/Plan provenance; ordinary and typed producers own disjoint spans in one original snapshot. Existing ordinary complete/compact semantics stay in place. | Small adapter and merge boundary; genuine mixed parsing and shared budgets still required before public use. Recommended first slice. |
|
||||
| B. Replace both producers with one shared semantic edit-plan/AST representation | Could eventually centralize token, subtree, insertion and relocation ownership. | Requires migrating ordinary EditValue/SyntaxUpdate, structural comment ownership and typed paths at once; substantially more compatibility risk before the first rename benefit. Revisit only when demonstrated structural composition needs justify it. |
|
||||
|
||||
Applying typed output before ordinary offsets is invalid under either design. Canonicalizing a mixed file as a bridge would destroy required authored preservation.
|
||||
|
||||
## Candidate A: concrete interface and file plan
|
||||
|
||||
1. **`analysis_source/update.rs`: export an opaque borrowed batch.** Proposed signatures are sketches, not existing APIs:
|
||||
|
||||
```rust
|
||||
Plan::validated_original_edits(&self, current, budget)
|
||||
-> Result<ValidatedRenameEdits<'_>, Error>
|
||||
ValidatedRenameEdits::iter(&self)
|
||||
-> impl ExactSizeIterator<Item = OriginalNameEdit<'_>>
|
||||
```
|
||||
|
||||
`OriginalNameEdit` exposes read-only `location: {file, original_span}`, `original: &str`, `replacement: &str`, and `occurrence: &OccurrenceKey`. All fields/constructors remain private with getters; records and batch borrow the Plan/index. Validate the whole batch before returning it. The bundle collector accepts the batch itself and iterates every record; it does not accept a caller-selected subset as a complete rename. An empty/no-op batch is valid. Revalidate complete source equality at final application, not merely iterator construction.
|
||||
|
||||
Factor today's `validate` into (a) ownership/spelling/order/completeness validation and (b) output-length preflight. Keep standalone `Plan::renames`/`apply` behavior. For mixed planning, add a narrowly named composition constructor sharing (a), without checking a fictitious typed-only intermediate output size. Otherwise a typed expansion can be rejected even when a simultaneous ordinary shrink makes the actual final bundle fit. Plan-record/name-memory bounds still apply before allocations, and final combined-output bounds are mandatory. Do not use a fresh Budget to bypass the intermediate check.
|
||||
|
||||
2. **`source_project/original_edits.rs` (new private module): bind and merge.** A `SourceSnapshot` borrows the complete original `SourceBundle`, including entry path and every file. A private file table maps each numeric typed file key to exactly one canonical LogicalPath from that snapshot, fixed by the parse session. No recomputation from a different traversal and no caller-supplied unchecked file map. The collector stores typed provenance and ordinary owner/role beside each borrowed/owned replacement until validation completes.
|
||||
|
||||
Collect ordinary `(span, text)` records through an internal adapter that records their expected original substring and source owner; typed records retain their full qualified key. Ordinary owns ordinary headers/fields/references and project header/description; typed owns typed headers and typed occurrences, including references to ordinary targets. Explicitly exclude typed declaration spans from generic SyntaxUpdate so both producers never independently own the same token. A shared physical span is a collision, not a deduplication opportunity.
|
||||
|
||||
Preflight every file before allocating output: exact entry/files/bytes equality, known mapped file, half-open UTF-8 boundaries, expected old bytes, producer ownership, complete typed set, checked counts/text sizes, sorted disjoint spans, per-file and aggregate final lengths. Reject duplicate nonempty ranges even when their replacements agree. Initially reject ambiguous insertions at a replacement boundary and multiple insertions at one position; existing ordinary insertion groups can enter only after their producer explicitly coalesces them in a defined order into one witnessed insertion. The new boundary must not inherit arbitrary concatenation of equal nonempty spans from `apply_edits`.
|
||||
|
||||
Emit each file once by copying untouched original slices and final replacements in ascending original order. Return a new candidate bundle only after complete validation; preserve the input on all failures. This is the sole text application for the supported mixed slice. Entry/include strings, authored paths, comments, literal spellings, line endings and untouched files remain exact.
|
||||
|
||||
3. **`source_project.rs`: route the narrow scalar slice.** Extract/retain ordinary edit collection before the existing application point. Add typed batches there, resolve their file keys through the snapshot, and call the new compositor once. Guard the supported scope before any output construction; no declaration relocation/default-file work is hidden in the adapter. Preserve the stale check that recompiles publicly mutable `CompiledSourceProject` fields and checks index/model/bundle, or replace it only with an equally strong opaque checked-snapshot boundary as part of separately reviewed work.
|
||||
|
||||
4. **Later `language.rs` / `analysis_source/index.rs` integration:** one opaque parsed-bundle result must carry full-buffer tokens, typed declaration units, lowerer result and file table from the same parse. Construct Index through that provenance, not a newly exposed raw `build(Lowered, spans)` seam. All offsets remain file-relative full-original-buffer byte offsets, including typed declarations after includes or ordinary declarations. Never remove/include-mask bytes, concatenate fragments, or rescan a substring and silently treat its offsets as full-file offsets. Existing local-slice ordinary helpers translate their known base exactly once. No typed rename offset receives a second include/header adjustment.
|
||||
|
||||
## Namespace, resource and acceptance invariants
|
||||
|
||||
Global aliases share the complete project namespace. Scoped names are keyed by `(calculation Id, ParameterId)`; stable simultaneous final-name validation must include unchanged ordinary globals and unchanged parameters. Reused parameter UUIDs across calculations and UUID equality with a global remain legal. Occurrence identity retains owner, binding field (including override property), complete structural call path, role and qualified target. Call labels bind to the callee, never caller scope or text alone. Rename-only changes preserve authored parameter/call-argument order; reordering unrelated model vectors is outside the narrow rename plan.
|
||||
|
||||
Use one inherited parse budget across all reachable ordinary/typed units; charge original bytes/tokens/literals once. Adapter validation does not reset/recharge parsing or numeric work. Combine ordinary+typed edit-record and owned replacement-text counts before reserving the merged list; do not treat each producer passing its own ceiling as aggregate admission. Preflight the complete final bundle against both inherited source8 output bounds and caller ProjectLimits, including unchanged files. A fresh aggregate budget belongs only to the next complete candidate parse. The existing phase byte check is a bounded text-size policy, not an exact heap-allocation accounting claim.
|
||||
|
||||
For an integrated candidate, recompile the **actual emitted complete bundle**, run existing structural/static admission through its normal validator (no redundant manual second admission), and require the entire resulting Project to equal the independently intended Project before returning/installing source/history. A parseable output that omits an unchanged definition is failure. Pure adapter tests cannot certify this currently unavailable mixed public path. Preserve the closed public dispatch and checked-save boundaries until the parser/lowerer/migration/acceptance work is integrated and verified.
|
||||
|
||||
## Prerequisites versus deliverables available now
|
||||
|
||||
**Implementable now on the private analysis branch:** borrowed validated batch export, split validation/preflight internals, and tests for binding/completeness/no output construction. A private composition evaluator can combine this batch with independently authored ordinary edit witnesses and full-buffer expected strings. That demonstrates the merge algorithm, not production ordinary edit generation or mixed compilation. No public schema switch, native GUI, metamodel extension or full S4 completion is needed for this bounded deliverable.
|
||||
|
||||
**Actually blocking production mixed integration:** root4ffff is still schema7 and has neither the private analysis model/language integration nor the private raw scanner; its tokenizer still rejects typed punctuation/converts ordinary numbers. Reconcile the analysis contribution with current ordinary CompleteForEdit/Compact work instead of replacing current root files wholesale. A real mixed `parse_unit`/edit-position branch, one shared scanner/cursor/bundle budget, full global/signature lowering and opaque Index provenance are required. Stabilize missing global/parameter IDs into exact returned files and reindex before granting edit authority. Complete-output source8 compile/admission, legacy bridge/migration and checked-save acceptance remain required public gates. Root identity correspondence also needs a distinct definition category before enabling reidentity; current fallback category is node placement. These are dependencies, not permission to invent a new model or activate source8 in this adapter.
|
||||
|
||||
**Not blockers for the read-only batch:** subtree writing, comment salvage for removed nodes, parameter insertion/deletion, declaration movement/new-file ownership, migration activation, evaluation and further metamodel work. Keep them out of the initial patch. The existing Writer supplies the future changed-subtree primitive once its real parent/side context and structural ownership are integrated.
|
||||
|
||||
## Six independent falsifiers (all proposed, unrun here)
|
||||
|
||||
1. **Same-file offset cascade.** Independently authored mixed CRLF source with Unicode trivia, an ordinary global header/reference before a calculation and an ordinary scalar field after it. Simultaneously lengthen a global alias, shrink a parameter name and change the later scalar. Assert the exact entire expected file literal, all untouched bytes and intended Project; applying either producer first must fail the oracle.
|
||||
2. **Include/file-coordinate binding.** Root includes two nested-path modules; typed and ordinary declarations share a module after an include/comment prefix, and the same numeric offsets occur in another module. Exact expected full bundle verifies edits land in the owning file at its original absolute offset. Wrong/missing/reordered key-to-path mapping rejects before output; include spellings and ownership stay exact.
|
||||
3. **Repeated/reordered calls and namespace swaps.** Two calculations reuse parameter IDs/names, one equals a global UUID. Include two reordered calls to one callee and one to the other, with separate comments. Swap the first callee's two parameter names and two global aliases simultaneously. Independently enumerate every declaration/use/label span: four labels for the repeated callee remain distinct; the other callee's labels, strings and argument order remain unchanged. Add a final-name collision negative control.
|
||||
4. **Whole-snapshot and Unicode staleness.** After planning, independently mutate an unrelated file/comment, add/remove a file, or change entry while preserving file text. Each fails before output. Separately forge a span inside a multibyte scalar or attach a valid-looking token from another owner; equal spelling/length must not authorize it. A byte-identical current snapshot is the positive control.
|
||||
5. **Producer overlap.** Ordinary whole-field replacement contains a typed rename; equal nonempty spans from both producers; duplicate typed occurrence; insertion at a replacement boundary. Each is rejected atomically. Adjacent disjoint ranges and one explicitly coalesced insertion are positives. No replacement concatenation, silent dropping or producer-order choice is allowed.
|
||||
6. **Aggregate output and acceptance.** Independent multi-file edits each fit a producer-local ceiling but exceed the combined cap by one byte; require refusal with unchanged input. Exact-cap positive and typed-growth/ordinary-shrink compensation positive enforce final-output accounting. At integration, deliberately omit an otherwise valid unchanged definition/reference edit from the emitted candidate: exact-output recompile/admission plus whole-Project equality must refuse it. No model projection or compiler success alone can pass.
|
||||
|
||||
All positive adapter fixtures also assert source/numeric usage is unchanged by export/merge and that exporting does not construct whole-file typed output. Integrated variants add complete output recompile, intended-Project equality and history/save refusal on failure.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user