Checkpoint source 7 workflows and in-progress diagram authoring redesign
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
This commit is contained in:
@@ -14,6 +14,8 @@ use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
mod communication;
|
||||
pub(crate) mod recovery;
|
||||
#[cfg(test)]
|
||||
mod transition_tests;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
@@ -30,9 +32,12 @@ pub enum Event {
|
||||
Source(Choice),
|
||||
Target(Choice),
|
||||
Scope(String),
|
||||
Destination(Choice),
|
||||
MappingTarget(Id, Choice),
|
||||
MappingKind(Id, Kind),
|
||||
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
||||
OperationOmission(Id, bool),
|
||||
OperationRationale(Id, String),
|
||||
Resolve(Id, TransitionField, FieldDecision),
|
||||
Preview,
|
||||
}
|
||||
@@ -265,6 +270,157 @@ pub struct Transition {
|
||||
pub plan: TransitionPlan,
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
}
|
||||
impl Transition {
|
||||
fn presentation(&self, p: &Project) -> (Project, Vec<Id>) {
|
||||
let mut candidate = p.clone();
|
||||
for command in &self.plan.commands {
|
||||
match command {
|
||||
Command::CreateElement(e) | Command::EditElement(_, e) => {
|
||||
candidate.elements.insert(e.id, e.clone());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let targets = self
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.target)
|
||||
.filter(|id| candidate.elements.contains_key(id))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
(candidate, targets)
|
||||
}
|
||||
fn automatic_behavior(&self, p: &Project) -> bool {
|
||||
self.request
|
||||
.sources
|
||||
.first()
|
||||
.is_some_and(|id| crate::behavior::selected_behavior(p, *id).is_some())
|
||||
}
|
||||
fn new_counterpart(&self, p: &Project) -> bool {
|
||||
self.request.sources.first().is_some_and(|source| {
|
||||
self.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == *source)
|
||||
.map_or_else(
|
||||
|| {
|
||||
!p.traces
|
||||
.values()
|
||||
.any(|t| t.oa == *source || t.sa == *source)
|
||||
&& !self
|
||||
.request
|
||||
.mappings
|
||||
.iter()
|
||||
.any(|m| m.source == *source && m.target.is_some())
|
||||
},
|
||||
|c| c.action == TransitionAction::Create,
|
||||
)
|
||||
})
|
||||
}
|
||||
fn boundary_choice(&self, p: &Project, conflict: &TransitionConflict) -> bool {
|
||||
conflict.field.is_none()
|
||||
&& conflict.target.is_none()
|
||||
&& conflict.message
|
||||
== "Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred."
|
||||
&& conflict
|
||||
.source
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.is_some_and(|e| {
|
||||
matches!(
|
||||
e.kind(),
|
||||
Kind::Entity | Kind::Actor | Kind::System | Kind::SystemActor
|
||||
)
|
||||
})
|
||||
}
|
||||
fn destination_error(&self, p: &Project) -> Option<String> {
|
||||
let id = self.request.destination_diagram?;
|
||||
let (candidate, targets) = self.presentation(p);
|
||||
match candidate.diagrams.get(&id) {
|
||||
None => Some("The chosen result diagram is missing. Your choice is retained; choose another diagram.".into()),
|
||||
Some(view) if !result_view_compatible(&candidate, view, &targets, self.request.direction.target_layer()) =>
|
||||
Some("The chosen result diagram cannot show every transitioned element. Choose another diagram; no model changes have been applied.".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
|
||||
(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| {
|
||||
diagram.projection.includes(e.kind())
|
||||
&& diagram.projection.state_machine().is_none_or(|machine| {
|
||||
oa_core::projection::state_machine_contains(p, machine, *id)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
fn automatic_result_view(p: &Project, targets: &[Id], layer: Layer) -> Option<Id> {
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer && result_view_compatible(p, d, targets, layer))
|
||||
.filter_map(|d| {
|
||||
Some((
|
||||
match d.projection {
|
||||
ProjectionKind::Allocation => 0,
|
||||
ProjectionKind::Overview => 1,
|
||||
ProjectionKind::Decomposition => 2,
|
||||
_ => return None,
|
||||
},
|
||||
d.id,
|
||||
))
|
||||
})
|
||||
.min()
|
||||
.map(|(_, id)| id)
|
||||
}
|
||||
|
||||
fn place_transition_results(
|
||||
before: &Project,
|
||||
candidate: &mut Project,
|
||||
diagram: Id,
|
||||
targets: &[Id],
|
||||
) {
|
||||
// Keep every accepted placement and route. Only newly introduced placements
|
||||
// receive space below the accepted projected bounds, including tall/nested nodes.
|
||||
let accepted_nodes = before.diagrams.get(&diagram).map(|d| &d.nodes);
|
||||
let mut occupied = candidate.diagrams[&diagram].clone();
|
||||
occupied
|
||||
.nodes
|
||||
.retain(|id, _| accepted_nodes.is_some_and(|nodes| nodes.contains_key(id)));
|
||||
let projected = oa_core::projection::project_diagram(candidate, &occupied);
|
||||
let mut bottom = projected
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| n.position.y + n.size.height)
|
||||
.chain(
|
||||
occupied
|
||||
.nodes
|
||||
.values()
|
||||
.map(|n| n.position.y + n.size.height),
|
||||
)
|
||||
.fold(20.0_f32, f32::max)
|
||||
+ 40.;
|
||||
for target in targets {
|
||||
if candidate.diagrams[&diagram].nodes.values().any(|n| {
|
||||
n.element == *target && accepted_nodes.is_some_and(|nodes| nodes.contains_key(&n.id))
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let node = candidate.place(diagram, *target, Position { x: 60., y: bottom });
|
||||
let placement = candidate
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap();
|
||||
placement.position = Position { x: 60., y: bottom };
|
||||
bottom += placement.size.height + 40.;
|
||||
}
|
||||
}
|
||||
impl Workflow {
|
||||
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
|
||||
if p.layer_of(selected).is_none()
|
||||
@@ -458,7 +614,34 @@ impl Workflow {
|
||||
_ => {}
|
||||
},
|
||||
Self::Transition(d) => {
|
||||
match &event {
|
||||
Event::OperationOmission(source, enabled) => {
|
||||
d.request
|
||||
.operation_omissions
|
||||
.retain(|v| v.source != *source);
|
||||
if *enabled {
|
||||
d.request.operation_omissions.push(OperationOmission {
|
||||
source: *source,
|
||||
rationale: String::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::OperationRationale(source, raw) => {
|
||||
if let Some(decision) = d
|
||||
.request
|
||||
.operation_omissions
|
||||
.iter_mut()
|
||||
.find(|v| v.source == *source)
|
||||
{
|
||||
decision.rationale = raw.clone();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match event {
|
||||
Event::Destination(choice) => {
|
||||
d.request.destination_diagram = (!choice.id.is_nil()).then_some(choice.id);
|
||||
}
|
||||
Event::CommunicationPort(exchange, source, value) => {
|
||||
d.choose_communication_port(p, exchange, source, value)?;
|
||||
}
|
||||
@@ -580,29 +763,27 @@ impl Workflow {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
let layer = d.request.direction.target_layer();
|
||||
let diagram = d
|
||||
.request
|
||||
.destination_diagram
|
||||
.or_else(|| {
|
||||
candidate
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == layer)
|
||||
.map(|d| d.id)
|
||||
})
|
||||
.unwrap_or_else(|| candidate.ensure_diagram(layer));
|
||||
for change in &d.plan.changes {
|
||||
if candidate.elements.contains_key(&change.target) {
|
||||
let index = candidate.diagrams[&diagram].nodes.len();
|
||||
candidate.place(
|
||||
diagram,
|
||||
change.target,
|
||||
Position {
|
||||
x: 60. + (index % 3) as f32 * 280.,
|
||||
y: 60. + (index / 3) as f32 * 150.,
|
||||
},
|
||||
);
|
||||
let targets: Vec<_> = d
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| c.target)
|
||||
.filter(|id| candidate.elements.contains_key(id))
|
||||
.collect();
|
||||
let diagram = if let Some(id) = d.request.destination_diagram {
|
||||
let view = candidate.diagrams.get(&id).ok_or("The chosen result diagram no longer exists. Choose another result diagram.")?;
|
||||
if !result_view_compatible(&candidate, view, &targets, layer) {
|
||||
return Err("The chosen result diagram cannot show every transitioned element. Choose a compatible result diagram.".into());
|
||||
}
|
||||
id
|
||||
} else {
|
||||
automatic_result_view(&candidate, &targets, layer)
|
||||
.unwrap_or_else(|| candidate.ensure_diagram(layer))
|
||||
};
|
||||
// Explicit behavior destinations retain the core plan's
|
||||
// placements; free-space placement is an architecture aid.
|
||||
if !d.automatic_behavior(p) {
|
||||
place_transition_results(p, &mut candidate, diagram, &targets);
|
||||
}
|
||||
return execute_batch(&candidate, &[])
|
||||
.map(Some)
|
||||
@@ -671,6 +852,9 @@ impl Workflow {
|
||||
let kinds = RelationKind::ALL
|
||||
.into_iter()
|
||||
.filter(|kind| {
|
||||
if *kind == RelationKind::ContextualLink {
|
||||
return false;
|
||||
}
|
||||
*kind == RelationKind::ExchangeAllocation
|
||||
|| Kind::ALL.into_iter().any(|source| {
|
||||
source.supports(d.layer)
|
||||
@@ -827,7 +1011,11 @@ impl Workflow {
|
||||
}
|
||||
}
|
||||
Self::Transition(d) => {
|
||||
content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED));
|
||||
content=content.push(text(if d.new_counterpart(p) {
|
||||
"Create a linked counterpart in the other layer. The source element stays in its current layer."
|
||||
} else {
|
||||
"Review names and descriptions of existing traced counterparts. For structural changes, use Review counterpart changes…"
|
||||
}).size(12).color(style::MUTED));
|
||||
let scope = match d.request.scope {
|
||||
DependencyScope::SelectedOnly => "Selected only",
|
||||
DependencyScope::Required => "Required references",
|
||||
@@ -856,6 +1044,63 @@ impl Workflow {
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
let (presentation, targets) = d.presentation(p);
|
||||
let target_layer = d.request.direction.target_layer();
|
||||
let automatic = if d.automatic_behavior(p) {
|
||||
"Automatic · open the counterpart in Behavior".into()
|
||||
} else if let Some(id) =
|
||||
automatic_result_view(&presentation, &targets, target_layer)
|
||||
{
|
||||
format!("Automatic · {}", p.named(id))
|
||||
} else {
|
||||
format!("Automatic · create {target_layer} overview")
|
||||
};
|
||||
let mut diagrams = vec![Choice {
|
||||
id: Id::nil(),
|
||||
label: automatic,
|
||||
}];
|
||||
diagrams.extend(
|
||||
presentation
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|view| {
|
||||
result_view_compatible(&presentation, view, &targets, target_layer)
|
||||
})
|
||||
.map(|view| Choice {
|
||||
id: view.id,
|
||||
label: format!("{} · {}", view.name, view.projection),
|
||||
}),
|
||||
);
|
||||
let selected = d.request.destination_diagram.unwrap_or(Id::nil());
|
||||
if !diagrams.iter().any(|choice| choice.id == selected) {
|
||||
diagrams.push(Choice {
|
||||
id: selected,
|
||||
label: format!("Unavailable result diagram · {}", p.named(selected)),
|
||||
});
|
||||
}
|
||||
let selected = diagrams
|
||||
.iter()
|
||||
.find(|choice| choice.id == selected)
|
||||
.cloned();
|
||||
content = content.push(
|
||||
column![
|
||||
text("Show result in").size(12),
|
||||
pick_list(diagrams, selected, |choice| Message::Workflow(
|
||||
Event::Destination(choice)
|
||||
))
|
||||
.width(Fill)
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
if let Some(error) = d.destination_error(p) {
|
||||
content = content.push(
|
||||
text(error)
|
||||
.size(12)
|
||||
.color(iced::Color::from_rgb8(178, 54, 58)),
|
||||
);
|
||||
}
|
||||
let sources = d
|
||||
.request
|
||||
.sources
|
||||
@@ -865,6 +1110,38 @@ impl Workflow {
|
||||
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
|
||||
.collect::<BTreeSet<_>>();
|
||||
for source in sources {
|
||||
if d.request.direction == TransitionDirection::SaToOa
|
||||
&& matches!(
|
||||
p.elements.get(&source).and_then(|e| e.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(_),
|
||||
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
let omission = d
|
||||
.request
|
||||
.operation_omissions
|
||||
.iter()
|
||||
.find(|v| v.source == source);
|
||||
content=content.push(text(format!("Technical operation · {}",p.named(source))).size(13))
|
||||
.push(checkbox(omission.is_some()).label("Explicitly omit the interface operation in the OA counterpart").on_toggle(move|enabled|Message::Workflow(Event::OperationOmission(source,enabled))));
|
||||
if let Some(omission) = omission {
|
||||
content = content.push(
|
||||
text_input("Required engineering rationale", &omission.rationale)
|
||||
.on_input(move |raw| {
|
||||
Message::Workflow(Event::OperationRationale(source, raw))
|
||||
})
|
||||
.padding(8)
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
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
|
||||
@@ -947,6 +1224,11 @@ impl Workflow {
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
if matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||
&& d.request.direction == TransitionDirection::OaToSa
|
||||
{
|
||||
item = item.push(text("System is the system of interest being designed. System actor remains outside that boundary. Choose the intended role; it is never inferred from the name.").size(11));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ports) = d.communication_view(p, source) {
|
||||
@@ -995,7 +1277,7 @@ impl Workflow {
|
||||
fields.sort_by_key(|(source, field)| (*source, *field as u8));
|
||||
let mut general_conflicts = column![].spacing(8);
|
||||
for conflict in &d.plan.conflicts {
|
||||
if conflict.field.is_none() {
|
||||
if conflict.field.is_none() && !d.boundary_choice(p, conflict) {
|
||||
general_conflicts = general_conflicts.push(
|
||||
text(&conflict.message)
|
||||
.size(12)
|
||||
@@ -1128,9 +1410,10 @@ impl Workflow {
|
||||
}
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} changes · {} unresolved conflicts · {} modeling warnings",
|
||||
"{} changes · {} required boundary choices · {} unresolved conflicts · {} modeling warnings",
|
||||
d.plan.changes.len(),
|
||||
d.plan.conflicts.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),
|
||||
@@ -1142,7 +1425,7 @@ impl Workflow {
|
||||
Self::Ports(_) => false,
|
||||
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
|
||||
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
|
||||
Self::Transition(d) => d.plan.conflicts.is_empty(),
|
||||
Self::Transition(d) => d.plan.conflicts.is_empty() && d.destination_error(p).is_none(),
|
||||
};
|
||||
if let Some(Err(error)) = relation_plan {
|
||||
content = content.push(
|
||||
@@ -1163,9 +1446,18 @@ impl Workflow {
|
||||
.padding(10)
|
||||
.style(style::nav)
|
||||
.on_press(Message::Workflow(Event::Cancel)),
|
||||
button(text("Apply reviewed change").size(12))
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
button(
|
||||
text(
|
||||
if matches!(self, Self::Transition(d) if d.new_counterpart(p)) {
|
||||
"Create counterpart"
|
||||
} else {
|
||||
"Apply reviewed change"
|
||||
}
|
||||
)
|
||||
.size(12)
|
||||
)
|
||||
.padding(10)
|
||||
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
|
||||
]
|
||||
.spacing(10)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user