Add named behavior creation and verified textual authoring examples

This commit is contained in:
2026-09-11 04:37:07 -07:00
committed by Codex-ytr83r
parent c81e285573
commit e207b04114
76 changed files with 8940 additions and 426 deletions
+45 -3
View File
@@ -13,6 +13,7 @@ mod transition_context_tests;
mod view_copy_tests;
use crate::forms::Draft;
use crate::{
behavior,
document::Document,
source_workspace::{self, LIMITS, Workspace},
};
@@ -849,6 +850,7 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let created = self.behavior.creation.created_by(self.project(), &command);
let scenario_selection = self
.behavior
.scenario
@@ -856,7 +858,16 @@ impl App {
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = scenario_selection.or(selected) {
if let Some(created) = created {
self.layer = created.layer;
self.selected = created.behavior;
self.selected_node = None;
self.behavior.active_owner = Some(created.behavior);
self.behavior.diagram = behavior::state_view::State::default();
self.behavior.diagram.diagram = created.diagram;
self.page = Page::Behavior;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
} else if let Some(id) = scenario_selection.or(selected) {
self.selected = id;
}
self.sync();
@@ -1020,6 +1031,8 @@ impl App {
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
behavior::creation::subscription(self.behavior.creation.has_pending())
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
@@ -1741,6 +1754,10 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
if self.behavior.creation.has_pending() {
self.behavior.creation.acknowledge_commit();
self.status = "Behavior creation cancelled".into();
}
if self.connection.take().is_some() {
self.status = "Drawing cancelled · Select / move is active".into();
}
@@ -2284,7 +2301,28 @@ impl App {
}
Message::InspectorTab(tab) => self.inspector_tab = tab,
Message::Behavior(event) => {
if event.starts_form() && !self.behavior.has_pending() && !self.apply() {
if matches!(&event, behavior::Event::Creation(behavior::creation::Event::Open(layer)) if *layer != self.layer)
{
self.status =
"The active layer changed. Choose New in the current layer.".into();
return Task::none();
}
let creating = matches!(
&event,
behavior::Event::Creation(behavior::creation::Event::Open(..))
);
let focus_name = matches!(
&event,
behavior::Event::Creation(behavior::creation::Event::Choose(..))
);
if creating
&& (self.behavior.has_pending() || self.source_dirty() || self.draft_dirty())
{
self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into();
return Task::none();
}
if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply()
{
return Task::none();
}
if event.is_submit() && (self.source_dirty() || self.draft_dirty()) {
@@ -2303,7 +2341,11 @@ impl App {
}
}
}
Ok(None) => {}
Ok(None) => {
if focus_name {
return iced::widget::operation::focus(behavior::creation::NAME_INPUT);
}
}
Err(error) => self.status = error,
}
}
+11 -1
View File
@@ -173,7 +173,12 @@ impl App {
self.behavior = restored.behavior;
self.workflow = restored.workflow;
let focus = self.behavior.pending_owner().unwrap_or(self.selected);
if let Some(layer) = self.project().layer_of(focus) {
if let Some(layer) = self
.behavior
.creation
.pending_layer()
.or_else(|| self.project().layer_of(focus))
{
self.layer = layer;
self.explorer_index = crate::explorer::Index::new(self.project(), layer);
self.active_diagram = self.diagram_for(layer, focus);
@@ -209,6 +214,11 @@ impl App {
.is_some_and(architecture_authoring::State::inline)
{
Task::batch([lint, iced::widget::operation::focus("architecture-name")])
} else if self.page == Page::Behavior && self.behavior.creation.created().is_some() {
Task::batch([
lint,
iced::widget::operation::focus(behavior::creation::NAME_INPUT),
])
} else {
lint
}
+43 -9
View File
@@ -14,6 +14,7 @@ use iced::{
};
use oa_core::*;
use std::collections::BTreeMap;
pub(crate) mod creation;
pub(crate) mod scenario;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
@@ -734,6 +735,7 @@ mod scenario_scroll {
}
#[derive(Debug, Clone)]
pub enum Event {
Creation(creation::Event),
Topology(topology::Event),
Scenario(scenario::Event),
States(states::Event),
@@ -743,7 +745,8 @@ impl Event {
pub fn starts_form(&self) -> bool {
matches!(
self,
Self::Topology(topology::Event::Start(..))
Self::Creation(creation::Event::Open(..))
| Self::Topology(topology::Event::Start(..))
| Self::Scenario(scenario::Event::Start(..))
| Self::States(states::Event::Start(..) | states::Event::Edit(..))
)
@@ -752,6 +755,7 @@ impl Event {
matches!(
self,
Self::Submit
| Self::Creation(creation::Event::Submit)
| Self::Topology(topology::Event::Submit)
| Self::Scenario(scenario::Event::Submit)
| Self::States(states::Event::Submit)
@@ -760,6 +764,7 @@ impl Event {
}
#[derive(Default)]
pub struct State {
pub(crate) creation: creation::State,
pub active_owner: Option<Id>,
pub diagram: state_view::State,
pub(crate) topology: topology::State,
@@ -776,6 +781,7 @@ struct RecoveryDraft {
}
#[derive(serde::Serialize, serde::Deserialize)]
enum RecoveryKind {
Creation,
State,
Scenario,
Process,
@@ -785,6 +791,7 @@ impl State {
/// viewport geometry and document/revision tokens never cross a restart.
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
let drafts = [
(RecoveryKind::Creation, self.creation.recovery_snapshot()?),
(RecoveryKind::State, self.states.recovery_snapshot()?),
(RecoveryKind::Scenario, self.scenario.recovery_snapshot()?),
(RecoveryKind::Process, self.topology.recovery_snapshot()?),
@@ -820,6 +827,9 @@ impl State {
}
let mut restored = Self::default();
match saved.kind {
RecoveryKind::Creation => {
restored.creation = creation::State::from_recovery(saved.draft)?
}
RecoveryKind::State => restored.states = states::State::from_recovery(saved.draft)?,
RecoveryKind::Scenario => {
restored.scenario = scenario::State::from_recovery(saved.draft)?
@@ -834,7 +844,7 @@ impl State {
}
pub fn has_pending(&self) -> bool {
self.pending_owner().is_some()
self.creation.has_pending() || self.pending_owner().is_some()
}
pub fn pending_owner(&self) -> Option<Id> {
@@ -847,7 +857,9 @@ impl State {
/// Prepare the active form without discarding inputs. The caller must execute
/// this exact command and acknowledge only a successful document transaction.
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
if self.topology.pending_owner().is_some() {
if self.creation.has_pending() {
self.creation.prepare_pending(p).map(Some)
} else if self.topology.pending_owner().is_some() {
self.topology.prepare_pending(p).map(Some)
} else if self.scenario.pending_owner().is_some() {
self.scenario.prepare_pending(p).map(Some)
@@ -861,6 +873,7 @@ impl State {
/// Clear the active form after its prepared command commits successfully.
/// Rejected commands must leave the form available for repair and retry.
pub fn acknowledge_commit(&mut self) {
self.creation.acknowledge_commit();
self.topology.acknowledge_commit();
self.scenario.acknowledge_commit();
self.states.acknowledge_commit();
@@ -870,7 +883,8 @@ impl State {
if self.has_pending()
&& matches!(
&event,
Event::Topology(topology::Event::Start(..))
Event::Creation(creation::Event::Open(..))
| Event::Topology(topology::Event::Start(..))
| Event::Scenario(scenario::Event::Start(..))
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
)
@@ -888,6 +902,7 @@ impl State {
return Ok(None);
}
match event {
Event::Creation(event) => self.creation.update(p, event),
Event::Topology(event) => self.topology.update(p, event),
Event::Scenario(event) => self.scenario.update(p, event),
Event::States(event) => self.states.update(p, event),
@@ -901,6 +916,9 @@ impl State {
pub fn view(app: &App) -> Element<'_, Message> {
let p = app.project();
if let Some(card) = app.behavior.creation.view(p) {
return card.map(|event| Message::Behavior(Event::Creation(event)));
}
let choices = p
.elements
.values()
@@ -919,7 +937,16 @@ pub fn view(app: &App) -> Element<'_, Message> {
.cloned();
let mut out = column![heading("Behavior & scenarios", "Follow exchanges and operating conditions. Select a message, state or transition to edit it.")].spacing(14);
let Some(selected) = selected else {
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
return out
.push(label(
"Start a named process, scenario or state machine, then model its behavior here.",
))
.push(action(
"New",
Message::Behavior(Event::Creation(creation::Event::Open(app.layer))),
))
.padding(24)
.into();
};
let e = &p.elements[&selected.id];
let compact_canvas = matches!(
@@ -933,10 +960,17 @@ pub fn view(app: &App) -> Element<'_, Message> {
out = column![].spacing(6);
}
out = out.push(
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(if compact_canvas { 6 } else { 10 }),
row![
pick_list(choices, Some(selected), Message::BehaviorOwner)
.width(Fill)
.text_size(13)
.padding(if compact_canvas { 6 } else { 10 }),
action(
"New",
Message::Behavior(Event::Creation(creation::Event::Open(app.layer)))
)
]
.spacing(8),
);
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
return topology::view(app, e.id, out);
+665
View File
@@ -0,0 +1,665 @@
//! Local behavior creation. A draft is not a model object: only its validated
//! batch may cross Document::execute, and acknowledgement follows success.
use iced::{
Element as UiElement, Fill,
widget::{button, checkbox, column, container, row, scrollable, text, text_input},
};
use oa_core::*;
use serde::{Deserialize, Serialize};
const PAGE_SIZE: usize = 6;
pub const NAME_INPUT: &str = "behavior-creation-name";
// A focused text input consumes Escape. Preserve the application's modal
// priority by emitting its ordinary Escape message from this narrow bridge.
pub fn subscription(active: bool) -> iced::Subscription<()> {
if active {
iced::event::listen_with(captured_escape)
} else {
iced::Subscription::none()
}
}
fn captured_escape(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<()> {
(status == iced::event::Status::Captured
&& matches!(
event,
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
})
))
.then_some(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Family {
Sequence,
Scenario,
Machine,
}
impl Family {
fn kind(self, layer: Layer) -> Kind {
match (self, layer) {
(Self::Sequence, Layer::Oa) => Kind::Process,
(Self::Sequence, Layer::Sa) => Kind::FunctionalChain,
(Self::Scenario, _) => Kind::Scenario,
(Self::Machine, _) => Kind::StateMachine,
}
}
fn title(self, layer: Layer) -> &'static str {
match (self, layer) {
(Self::Sequence, Layer::Oa) => "Operational process",
(Self::Sequence, Layer::Sa) => "Functional chain",
(Self::Scenario, _) => "Scenario",
(Self::Machine, _) => "State machine",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Picker {
Owner,
Context,
}
impl Picker {
fn index(self) -> usize {
match self {
Self::Owner => 0,
Self::Context => 1,
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Open(Layer),
Choose(Family),
Name(String),
Owner(Id),
Viewpoint(ScenarioViewpoint),
Context(Option<Id>),
ContextOptions(bool),
FirstView(bool),
Search(Picker, String),
Page(Picker, usize),
Cancel,
Submit,
}
#[derive(Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Browser {
query: String,
page: usize,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Draft {
version: u32,
document: Id,
layer: Layer,
family: Option<Family>,
name: String,
owner: Option<Id>,
viewpoint: Option<ScenarioViewpoint>,
context: Option<Id>,
#[serde(default)]
context_open: bool,
first_view: bool,
element: Id,
diagram: Id,
placement: Id,
browsers: [Browser; 2],
}
#[derive(Default)]
pub struct State {
draft: Option<Draft>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Created {
pub behavior: Id,
pub layer: Layer,
pub diagram: Option<Id>,
}
impl State {
pub fn has_pending(&self) -> bool {
self.draft.is_some()
}
pub fn pending_layer(&self) -> Option<Layer> {
self.draft.as_ref().map(|d| d.layer)
}
/// Capture this before successful acknowledgement; never select these IDs
/// on mere planning or a rejected source/model transaction.
pub fn created(&self) -> Option<Created> {
let d = self.draft.as_ref()?;
d.family.map(|family| Created {
behavior: d.element,
layer: d.layer,
diagram: (family == Family::Machine && d.first_view).then_some(d.diagram),
})
}
/// Match before execution, then apply this target only after success.
/// Identity alone is insufficient: changed bodies or trailing operations
/// must not inherit the prepared creation's selection/opening contract.
pub fn created_by(&self, p: &Project, command: &Command) -> Option<Created> {
let target = self.created()?;
(self.prepare_pending(p).ok().as_ref() == Some(command)).then_some(target)
}
pub fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
self.draft
.as_ref()
.map(serde_json::to_value)
.transpose()
.map_err(|e| e.to_string())
}
pub fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft: Draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
if draft.version != 1 {
return Err("Unsupported behavior creation draft version.".into());
}
// Raw values and stale references remain editable; planning validates
// against the current document rather than silently fixing the draft.
Ok(Self { draft: Some(draft) })
}
pub fn acknowledge_commit(&mut self) {
self.draft = None;
}
pub fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
self.draft
.as_ref()
.ok_or("Choose New to start a behavior.")?
.command(p)
}
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
match event {
Event::Cancel => {
self.draft = None;
return Ok(None);
}
Event::Open(layer) => {
if self.has_pending() {
return Err("Create or cancel the pending behavior first.".into());
}
self.draft = Some(Draft {
version: 1,
document: p.id,
layer,
family: None,
name: String::new(),
owner: None,
viewpoint: None,
context: None,
context_open: false,
first_view: false,
element: Id::new_v4(),
diagram: Id::new_v4(),
placement: Id::new_v4(),
browsers: Default::default(),
});
return Ok(None);
}
Event::Submit => return self.prepare_pending(p).map(Some),
_ => {}
}
let d = self
.draft
.as_mut()
.ok_or("Choose New to start a behavior.")?;
if d.document != p.id {
return Err("This draft belongs to another document. Cancel it to start here.".into());
}
match event {
Event::Choose(family) => {
if d.family.is_some_and(|old| old != family) {
return Err(
"Cancel this draft before choosing a different behavior family.".into(),
);
}
d.family = Some(family);
}
Event::Name(name) => d.name = name,
Event::Owner(id) if d.family == Some(Family::Machine) => {
if !eligible(p, d.layer, Picker::Owner, id) {
return Err("Choose an existing participant in this layer.".into());
}
d.owner = Some(id);
}
Event::Viewpoint(viewpoint) if d.family == Some(Family::Scenario) => {
if !viewpoints(d.layer).contains(&viewpoint) {
return Err("Choose a scenario viewpoint from this layer.".into());
}
d.viewpoint = Some(viewpoint);
}
Event::Context(id) if d.family == Some(Family::Scenario) => {
if id.is_some_and(|id| !eligible(p, d.layer, Picker::Context, id)) {
return Err("Choose an existing same-layer scenario context.".into());
}
d.context = id;
}
Event::ContextOptions(open) if d.family == Some(Family::Scenario) => {
d.context_open = open;
}
Event::FirstView(value) if d.family == Some(Family::Machine) => d.first_view = value,
Event::Search(picker, query) => d.browsers[picker.index()] = Browser { query, page: 0 },
Event::Page(picker, page) => d.browsers[picker.index()].page = page,
Event::Cancel | Event::Open(_) | Event::Submit => unreachable!(),
_ => return Err("That field does not apply to this behavior family.".into()),
}
Ok(None)
}
}
fn viewpoints(layer: Layer) -> [ScenarioViewpoint; 2] {
match layer {
Layer::Oa => [
ScenarioViewpoint::OperationalActivity,
ScenarioViewpoint::ParticipantInteraction,
],
Layer::Sa => [
ScenarioViewpoint::Functional,
ScenarioViewpoint::BoundaryInterface,
],
}
}
fn viewpoint_title(viewpoint: ScenarioViewpoint) -> &'static str {
match viewpoint {
ScenarioViewpoint::OperationalActivity => "Activities · operational exchanges",
ScenarioViewpoint::ParticipantInteraction => "Participants · interactions",
ScenarioViewpoint::Functional => "Functions · functional exchanges",
ScenarioViewpoint::BoundaryInterface => "System boundary · interface exchanges",
ScenarioViewpoint::MixedLegacy => "Legacy mixed viewpoint",
}
}
fn eligible(p: &Project, layer: Layer, picker: Picker, id: Id) -> bool {
p.elements.get(&id).is_some_and(|e| {
e.layer() == layer
&& match picker {
Picker::Owner => validation::parent_permits(e.kind(), Kind::StateMachine),
Picker::Context => {
e.kind().is_sequence()
|| e.kind().is_work()
|| e.kind().is_performer()
|| matches!(
e.kind(),
Kind::Capability
| Kind::SystemCapability
| Kind::OperationalMission
| Kind::Mission
)
}
}
})
}
fn choices<'a>(p: &'a Project, layer: Layer, picker: Picker, query: &str) -> Vec<&'a Element> {
let query = query.trim().to_lowercase();
let mut matches: Vec<_> = p
.elements
.values()
.filter(|e| eligible(p, layer, picker, e.id))
.filter(|e| query.is_empty() || reference_label(p, e.id).to_lowercase().contains(&query))
.collect();
matches.sort_by(|a, b| {
a.name
.to_lowercase()
.cmp(&b.name.to_lowercase())
.then(a.id.cmp(&b.id))
});
matches
}
fn reference_context(p: &Project, e: &Element) -> String {
let mut names = Vec::new();
let mut parent = e.parent;
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = parent {
if !seen.insert(id) {
break;
}
let Some(ancestor) = p.elements.get(&id) else {
break;
};
names.push(ancestor.name.as_str());
parent = ancestor.parent;
}
names.reverse();
let location = if names.is_empty() {
format!("{} root", e.layer())
} else {
format!("{} / {}", e.layer(), names.join(" / "))
};
format!("{} · {} · {location}", e.kind(), e.alias)
}
fn reference_label(p: &Project, id: Id) -> String {
p.elements
.get(&id)
.map(|e| format!("{} · {}", e.name, reference_context(p, e)))
.unwrap_or_else(|| "Missing element — choose a replacement".into())
}
impl Draft {
fn command(&self, p: &Project) -> Result<Command, String> {
if self.document != p.id {
return Err("This draft belongs to another document.".into());
}
let family = self
.family
.ok_or("Choose the behavior you want to create.")?;
if self.name.trim().is_empty() {
return Err("Enter the behavior's name before creating it.".into());
}
if [self.element, self.diagram, self.placement]
.into_iter()
.any(|id| p.contains(id))
|| self.element == self.diagram
|| self.element == self.placement
|| self.diagram == self.placement
{
return Err(
"A reserved creation identity is already in use. Cancel and start a fresh draft."
.into(),
);
}
let kind = family.kind(self.layer);
let parent = if family == Family::Machine {
Some(
self.owner
.filter(|id| eligible(p, self.layer, Picker::Owner, *id))
.ok_or("Choose the participant that owns this state machine.")?,
)
} else {
if self.owner.is_some() {
return Err("Processes and scenarios belong at the layer root; context is a separate reference.".into());
}
None
};
if family != Family::Machine && self.first_view {
return Err("A state view can only accompany a state machine.".into());
}
let mut body = ElementBody::new(kind, self.layer)
.ok_or("This behavior is unavailable in this layer.")?;
if family == Family::Scenario {
let viewpoint = self
.viewpoint
.filter(|v| viewpoints(self.layer).contains(v))
.ok_or("Choose the scenario's viewpoint explicitly.")?;
if self
.context
.is_some_and(|id| !eligible(p, self.layer, Picker::Context, id))
{
return Err(
"The selected scenario context is missing or no longer compatible.".into(),
);
}
body = ElementBody::shared_in(
self.layer,
SharedBody::Scenario {
viewpoint,
context: self.context,
precondition: String::new(),
postcondition: String::new(),
steps: Vec::new(),
},
);
} else if self.viewpoint.is_some() || self.context.is_some() {
return Err(
"Scenario viewpoint and context cannot be applied to another behavior family."
.into(),
);
}
let element = Element {
id: self.element,
alias: p.fresh_alias(&self.name, "behavior", self.element),
name: self.name.clone(),
description: String::new(),
parent,
body,
};
let mut commands = vec![Command::CreateElement(element)];
if self.first_view {
let working = execute_batch(p, &commands).map_err(|e| e.to_string())?;
// Reuse the state editor's geometry contract. A new machine has one
// root placement; reserve both presentation IDs across retries.
let plan = super::state_edit::create_view(&working, self.element)
.map_err(|e| e.to_string())?;
let Command::Batch(mut view_commands) = plan.command else {
return Err("Unexpected state-view plan.".into());
};
if view_commands.len() != 2 {
return Err("Unexpected additional operation in the first state-view plan.".into());
}
let Some(Command::Layout(layout)) = view_commands.pop() else {
return Err("State-view plan must finish with its layout operation.".into());
};
let Some(Command::CreateDiagram(mut diagram)) = view_commands.pop() else {
return Err("State-view plan is missing its diagram.".into());
};
if diagram.id != layout || diagram.nodes.len() != 1 {
return Err("A first state view must contain only its new machine.".into());
}
let mut node = diagram
.nodes
.into_values()
.next()
.ok_or("State-view root is missing.")?;
if node.element != self.element {
return Err("State-view root differs from the new machine.".into());
}
diagram.id = self.diagram;
diagram.alias = working.fresh_alias(&diagram.name, "state_view", self.diagram);
node.id = self.placement;
node.alias = working.fresh_alias(
&format!("{} placement", self.name),
"placement",
self.placement,
);
diagram.nodes = [(node.id, node)].into_iter().collect();
commands.push(Command::CreateDiagram(diagram));
commands.push(Command::Layout(self.diagram));
}
let command = Command::Batch(commands);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
}
impl State {
/// Parent maps these local events into the existing guarded Behavior route.
/// The card's fields scroll; Create/Cancel stay outside that scroll region.
pub fn view<'a>(&'a self, p: &'a Project) -> Option<UiElement<'a, Event>> {
self.view_with(p)
}
fn view_with<'a, R: iced::advanced::text::Renderer + 'a>(
&'a self,
p: &'a Project,
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.draft.as_ref()?;
let mut fields = column![].spacing(10);
let title = if let Some(family) = d.family {
fields = fields.push(
text_input("Exact behavior name", &d.name)
.id(NAME_INPUT)
.on_input(Event::Name)
.on_submit(Event::Submit)
.padding(8),
);
match family {
Family::Sequence => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Involve existing work after creation.",
d.layer
))
.size(12),
);
}
Family::Scenario => {
fields = fields.push(
text(format!(
"Owned by the {} layer root. Choose what the lifelines represent:",
d.layer
))
.size(12),
);
for viewpoint in viewpoints(d.layer) {
fields = fields.push(
button(text(viewpoint_title(viewpoint)).size(13))
.style(if d.viewpoint == Some(viewpoint) {
button::primary
} else {
button::secondary
})
.on_press(Event::Viewpoint(viewpoint))
.padding(7),
);
}
let context_label = if d.context_open {
"Hide optional context".into()
} else if let Some(id) = d.context {
format!("Context: {} · Change…", reference_label(p, id))
} else {
"Add optional context…".into()
};
fields = fields.push(
button(text(context_label).size(12))
.on_press(Event::ContextOptions(!d.context_open))
.padding(6),
);
if d.context_open {
fields = fields.push(picker_view(p, d, Picker::Context));
}
}
Family::Machine => {
fields = fields.push(picker_view(p, d, Picker::Owner));
fields = fields.push(
checkbox(d.first_view)
.label("Create the first state view")
.on_toggle(Event::FirstView),
);
fields = fields.push(text("The machine starts without regions or states. Add those explicitly in its workspace.").size(12));
}
}
format!("New {}", family.title(d.layer))
} else {
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let purpose = match family {
Family::Sequence => "A path through existing work and exchanges",
Family::Scenario => "Ordered interactions from an explicit viewpoint",
Family::Machine => "States and modes of an existing participant",
};
fields = fields.push(
button(
column![text(family.title(d.layer)).size(15), text(purpose).size(12)]
.spacing(3),
)
.on_press(Event::Choose(family))
.padding(10)
.width(Fill),
);
}
format!("New behavior · {}", d.layer)
};
let mut actions =
row![button("Cancel · Esc").on_press(Event::Cancel).padding(8)].spacing(8);
if d.family.is_some() {
actions = actions.push(button("Create and open").on_press(Event::Submit).padding(8));
}
Some(
container(
column![
text(title).size(20),
scrollable(fields)
.id("behavior-creation-fields")
.height(Fill),
container(actions).id("behavior-creation-actions")
]
.spacing(12),
)
.padding(18)
.max_width(680)
.height(Fill)
.style(crate::style::card)
.into(),
)
}
}
fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
p: &'a Project,
d: &'a Draft,
picker: Picker,
) -> UiElement<'a, Event, iced::Theme, R> {
let browser = &d.browsers[picker.index()];
let options = choices(p, d.layer, picker, &browser.query);
let page = browser
.page
.min(options.len().saturating_sub(1) / PAGE_SIZE);
let selected = match picker {
Picker::Owner => d.owner,
Picker::Context => d.context,
};
let title = match picker {
Picker::Owner => "Owned by participant",
Picker::Context => "Scenario context · optional",
};
let mut out = column![
text(title).size(13),
text_input("Search names, aliases or parent context", &browser.query)
.on_input(move |query| Event::Search(picker, query))
.padding(6)
]
.spacing(5);
if let Some(id) = selected {
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
} else if picker == Picker::Context {
out = out.push(text("No context selected").size(12));
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
}
for e in options.iter().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let message = match picker {
Picker::Owner => Event::Owner(e.id),
Picker::Context => Event::Context(Some(e.id)),
};
out = out.push(
container(
button(
column![
text(&e.name).size(13),
text(reference_context(p, e)).size(11)
]
.spacing(2),
)
.style(if selected == Some(e.id) {
button::primary
} else {
button::secondary
})
.on_press(message)
.padding(6)
.width(Fill),
)
.id(format!("behavior-creation-candidate-{}", e.id)),
);
}
let mut navigation = row![text(format!("{} matches", options.len())).size(11)].spacing(8);
if page > 0 {
navigation = navigation.push(button("Previous").on_press(Event::Page(picker, page - 1)));
}
if (page + 1) * PAGE_SIZE < options.len() {
navigation = navigation.push(button("Next").on_press(Event::Page(picker, page + 1)));
}
if picker == Picker::Context && selected.is_some() {
navigation = navigation.push(button("Clear context").on_press(Event::Context(None)));
}
out.push(navigation).into()
}
#[cfg(test)]
mod app_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,340 @@
//! App-boundary regressions for the coordinated parent wiring. No I/O task is
//! polled: file dialogs, checkpoint writes and lesson loading remain inactive.
use super::*;
use crate::app::{App, Message, Page, Pending};
use std::sync::Arc;
fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(crate::behavior::Event::Creation(event)));
}
fn raw_property(app: &App) -> serde_json::Value {
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
}
fn begin(app: &mut App, layer: Layer, family: Family, name: &str) {
let _ = app.update(Message::Layer(layer));
let _ = app.update(Message::Page(Page::Behavior));
send(app, Event::Open(layer));
send(app, Event::Choose(family));
send(app, Event::Name(name.into()));
}
fn focused_escape() -> Message {
use iced::advanced::{Layout, Shell, layout, widget};
use iced::keyboard::{self, key::Named};
let mut input: iced::Element<'_, (), iced::Theme, ()> = text_input("Name", "Unfinished name")
.id(NAME_INPUT)
.on_input(|_| ())
.into();
let mut tree = widget::Tree::new(input.as_widget());
let size = iced::Size::new(300., 40.);
let node =
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
input.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
assert!(shell.is_event_captured());
assert_eq!(
captured_escape(
event.clone(),
iced::event::Status::Ignored,
iced::window::Id::unique()
),
None
);
captured_escape(
event,
iced::event::Status::Captured,
iced::window::Id::unique(),
)
.unwrap();
Message::Escape
}
#[test]
fn app_first_captured_escape_dismisses_file_or_pending_overlay_before_creation() {
for file in [false, true] {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Oa,
Family::Sequence,
"Keep while overlay closes",
);
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
if file {
app.file_prompt = Some(crate::app::FilePrompt {
operation: crate::app::FileOperation::Save,
path: "unsubmitted-name.arcadia".into(),
overwrite: false,
});
} else {
app.pending = Some(Pending::Walkthrough);
}
let _ = app.update(focused_escape());
assert!(app.file_prompt.is_none());
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(focused_escape());
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
}
#[test]
fn app_creation_opens_the_actual_new_owner_instead_of_first_or_previously_active_behavior() {
for layer in [Layer::Oa, Layer::Sa] {
let mut p = Project::empty();
let old = p
.add_element(Family::Sequence.kind(layer), layer, "Old behavior", None)
.unwrap();
let mut app = App::with_project(p.clone());
let _ = app.update(Message::Select(old));
begin(&mut app, layer, Family::Sequence, " New exact path 雨 ");
let target = app.behavior.creation.created().unwrap();
assert_ne!(target.behavior, old);
let before = app.document.bundle().clone();
send(&mut app, Event::Submit);
assert_eq!(app.page, Page::Behavior);
assert_eq!(app.layer, layer);
assert_eq!(app.selected, target.behavior);
assert_eq!(app.behavior.active_owner, Some(target.behavior));
assert_eq!(crate::behavior::selected_owner(&app), Some(target.behavior));
assert_eq!(
app.project().elements[&target.behavior].name,
" New exact path 雨 "
);
assert!(!app.behavior.has_pending());
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert_eq!(app.project().elements[&old], p.elements[&old]);
}
}
#[test]
fn app_machine_behavior_submit_opens_its_opted_in_view_as_one_undoable_transaction() {
for first_view in [false, true] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::System, Layer::Sa, "Flight system", None)
.unwrap();
let mut app = App::with_project(p.clone());
begin(&mut app, Layer::Sa, Family::Machine, " Flight modes 雨 ");
send(&mut app, Event::Owner(owner));
send(&mut app, Event::FirstView(first_view));
let target = app.behavior.creation.created().unwrap();
let before = app.document.bundle().clone();
let _ = app.update(Message::Behavior(crate::behavior::Event::Submit));
assert!(!app.behavior.has_pending(), "{}", app.status);
assert_eq!(app.selected, target.behavior);
assert_eq!(app.behavior.active_owner, Some(target.behavior));
assert_eq!(app.behavior.diagram.diagram, target.diagram);
assert_eq!(
crate::behavior::state_view::diagram(&app, target.behavior),
target.diagram
);
assert_eq!(app.project().elements[&target.behavior].parent, Some(owner));
assert_eq!(app.page, Page::Behavior);
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.project(), &p);
assert!(!app.document.can_undo());
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
}
}
#[test]
fn app_stale_layer_open_never_starts_or_replaces_a_draft() {
let mut app = App::with_project(Project::empty());
let _ = app.update(Message::Layer(Layer::Sa));
app.page = Page::Behavior;
let accepted = app.document.snapshot();
send(&mut app, Event::Open(Layer::Oa));
assert!(!app.behavior.has_pending());
assert_eq!(app.layer, Layer::Sa);
send(&mut app, Event::Open(Layer::Sa));
send(&mut app, Event::Choose(Family::Sequence));
send(&mut app, Event::Name("Retained system chain".into()));
let raw = app.behavior.recovery_snapshot().unwrap();
send(&mut app, Event::Open(Layer::Oa));
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn app_new_never_auto_applies_valid_or_invalid_property_and_source_drafts() {
for dirty in [
"property-valid",
"property-invalid",
"source-valid",
"source-invalid",
] {
let mut app = App::with_project(Project::empty());
app.page = Page::Behavior;
match dirty {
"property-valid" => app
.draft
.as_mut()
.unwrap()
.input("/name", "Raw property name".into()),
"property-invalid" => app.draft.as_mut().unwrap().input("/id", "-".into()),
"source-valid" => app.source_workspace.edit(
format!("{}\n// Retained comment\n", app.source_workspace.text()),
app.source_editor.cursor(),
),
_ => app
.source_workspace
.edit("unfinished source 雨".into(), app.source_editor.cursor()),
}
let accepted = app.document.snapshot();
let property = raw_property(&app);
let workspace = app.source_workspace.recovery_snapshot();
send(&mut app, Event::Open(Layer::Oa));
assert!(
!app.behavior.creation.has_pending(),
"{dirty}: New must refuse without applying another editor"
);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(raw_property(&app), property);
assert_eq!(
serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(),
serde_json::to_value(workspace).unwrap()
);
assert!(!app.document.can_undo());
}
}
#[test]
fn app_choosing_another_behavior_or_layer_cannot_commit_a_creation_draft() {
let mut p = Project::empty();
let old = p
.add_element(Kind::Process, Layer::Oa, "Old process", None)
.unwrap();
let mut app = App::with_project(p);
begin(&mut app, Layer::Oa, Family::Sequence, "Retain this process");
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let _ = app.update(Message::Layer(Layer::Sa));
let _ = app.update(Message::BehaviorOwner(crate::app::Choice {
id: old,
label: "Old process".into(),
}));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo());
}
#[test]
fn app_lesson_request_cancel_retains_creation_then_escape_discards_only_the_draft() {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Oa,
Family::Sequence,
"Unfinished engineering path",
);
let raw = app.behavior.recovery_snapshot().unwrap();
let accepted = app.document.snapshot();
let task = app.update(Message::Home(crate::home::Event::Walkthrough));
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
assert!(iced_runtime::task::into_stream(task).is_none());
let _ = app.update(Message::Escape);
assert!(app.pending.is_none());
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let _ = app.update(Message::Escape);
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn app_recovered_rootless_sa_creation_retains_layer_ids_and_other_raw_drafts() {
let mut app = App::with_project(Project::empty());
begin(
&mut app,
Layer::Sa,
Family::Scenario,
" Recovered system scenario 雨 ",
);
send(
&mut app,
Event::Viewpoint(ScenarioViewpoint::BoundaryInterface),
);
// Recovery can contain simultaneous drafts even though new input is guarded.
app.draft
.as_mut()
.unwrap()
.input("/name", "Retained property".into());
app.source_workspace.edit(
"unfinished source preserved".into(),
app.source_editor.cursor(),
);
let snapshot = crate::recovery::Snapshot::capture(
app.canvas_document,
&app.document,
&app.source_workspace,
app.draft.as_ref(),
&app.behavior,
None,
app.selected,
)
.unwrap();
let expected = snapshot.clone().restore().unwrap();
let raw = app.behavior.recovery_snapshot().unwrap();
let mut restored = App::with_project(Project::empty());
let _ = restored.update(Message::CheckpointLoaded(Ok(Arc::new(snapshot))));
assert_eq!(restored.page, Page::Behavior);
assert_eq!(restored.layer, Layer::Sa);
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(
restored.behavior.creation.created(),
app.behavior.creation.created()
);
assert_eq!(restored.project(), expected.document.project());
assert_eq!(restored.document.bundle(), expected.document.bundle());
assert_eq!(
restored.source_workspace.bundle(),
expected.workspace.bundle()
);
assert_eq!(raw_property(&restored), raw_property(&app));
let before = restored.document.snapshot();
send(&mut restored, Event::Submit);
assert!(Arc::ptr_eq(&before, &restored.document.snapshot()));
assert_eq!(restored.behavior.recovery_snapshot().unwrap(), raw);
assert_eq!(raw_property(&restored), raw_property(&app));
}
@@ -0,0 +1,599 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::ProjectLimits;
fn start(p: &Project, layer: Layer, family: Family, name: &str) -> State {
let mut state = State::default();
assert!(state.update(p, Event::Open(layer)).unwrap().is_none());
state.update(p, Event::Choose(family)).unwrap();
state.update(p, Event::Name(name.into())).unwrap();
state
}
#[test]
fn mounted_creation_keeps_actions_visible_and_renders_only_six_owner_candidates() {
use iced::advanced::{Layout, layout, widget};
use iced::{Rectangle, Size, Vector};
struct Geometry {
candidates: Vec<widget::Id>,
rendered: Vec<widget::Id>,
actions: Option<Rectangle>,
fields: Option<Rectangle>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("behavior-creation-actions")) {
self.actions = Some(bounds);
}
if let Some(id) = id.filter(|id| self.candidates.contains(id)) {
self.rendered.push(id.clone());
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("behavior-creation-fields")) {
self.fields = Some(bounds);
}
}
}
let mut p = Project::empty();
for index in 0..105 {
p.add_element(
Kind::Entity,
Layer::Oa,
&format!(
"{index:03} Long engineering participant name {}",
"雨 extended ".repeat(20)
),
None,
)
.unwrap();
}
let candidates: Vec<_> = p
.elements
.keys()
.map(|id| widget::Id::from(format!("behavior-creation-candidate-{id}")))
.collect();
for family in [Family::Sequence, Family::Scenario, Family::Machine] {
let state = start(&p, Layer::Oa, family, "Very long exact behavior name 雨");
for size in [
Size::new(500., 570.),
Size::new(1050., 570.),
Size::new(500., 320.),
] {
let mut view: UiElement<'_, Event, iced::Theme, ()> = state.view_with(&p).unwrap();
let mut tree = widget::Tree::new(view.as_widget());
let node =
view.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(Size::ZERO, size));
let mut geometry = Geometry {
candidates: candidates.clone(),
rendered: Vec::new(),
actions: None,
fields: None,
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &(), &mut geometry);
let actions = geometry.actions.unwrap();
let fields = geometry.fields.unwrap();
assert!(actions.x >= 0. && actions.y >= 0.);
assert!(
actions.x + actions.width <= size.width
&& actions.y + actions.height <= size.height
);
assert!(fields.height >= 100. && fields.y + fields.height <= actions.y);
assert_eq!(
geometry.rendered.len(),
if family == Family::Machine { 6 } else { 0 }
);
}
}
}
fn roundtrip(p: Project, state: &mut State) -> Project {
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert_eq!(
command,
state.prepare_pending(&p).unwrap(),
"preparation must retain reserved IDs"
);
let api: Command = serde_json::from_value(serde_json::to_value(&command).unwrap()).unwrap();
assert_eq!(api, command);
let expected = execute_batch(&p, &[api]).unwrap();
let target = state.created().unwrap();
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let before_source = document.bundle().clone();
document.execute(command).unwrap();
assert_eq!(document.project(), &expected);
assert!(
state.has_pending(),
"only successful acknowledgement clears raw fields"
);
state.acknowledge_commit();
assert!(!state.has_pending());
assert!(document.project().elements.contains_key(&target.behavior));
let after_source = document.bundle().clone();
assert!(document.undo());
assert_eq!(document.project(), &p);
assert_eq!(document.bundle(), &before_source);
assert!(!document.can_undo(), "one creation must be one Undo entry");
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &after_source);
let reopened = Document::from_bundle(after_source, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
expected
}
#[test]
fn oa_process_and_sa_chain_keep_exact_names_root_ownership_and_one_history_entry() {
for layer in [Layer::Oa, Layer::Sa] {
let p = Project::empty();
let name = " Supply \"water\" — режим ";
let mut state = start(&p, layer, Family::Sequence, name);
let target = state.created().unwrap();
let result = roundtrip(p, &mut state);
let element = &result.elements[&target.behavior];
assert_eq!(element.name, name);
assert_eq!(element.layer(), layer);
assert_eq!(
element.kind(),
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
}
);
assert_eq!(element.parent, None);
assert!(element.steps().unwrap().is_empty());
assert!(result.diagrams.is_empty());
assert!(result.relations.is_empty());
}
}
#[test]
fn all_four_viewpoints_are_explicit_and_survive_source_history_reopen() {
for layer in [Layer::Oa, Layer::Sa] {
for viewpoint in viewpoints(layer) {
let mut p = Project::empty();
let context = p
.add_element(
if layer == Layer::Oa {
Kind::Capability
} else {
Kind::SystemCapability
},
layer,
"Water supply",
None,
)
.unwrap();
let mut state = start(&p, layer, Family::Scenario, "Nominal supply");
assert!(
state
.prepare_pending(&p)
.unwrap_err()
.contains("explicitly")
);
assert!(
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::MixedLegacy))
.is_err()
);
let wrong = viewpoints(if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
})[0];
assert!(state.update(&p, Event::Viewpoint(wrong)).is_err());
state.update(&p, Event::Viewpoint(viewpoint)).unwrap();
state.update(&p, Event::Context(Some(context))).unwrap();
let id = state.created().unwrap().behavior;
let result = roundtrip(p, &mut state);
assert_eq!(
result.elements[&id].parent, None,
"context is not containment"
);
assert!(
matches!(result.elements[&id].shared(), Some(SharedBody::Scenario { viewpoint: actual, context: Some(actual_context), steps, .. }) if *actual == viewpoint && *actual_context == context && steps.is_empty())
);
}
}
}
#[test]
fn machine_requires_valid_owner_and_first_view_adds_no_region_or_state() {
for layer in [Layer::Oa, Layer::Sa] {
for first_view in [false, true] {
let mut p = Project::empty();
let owner = p
.add_element(
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
"Pump",
None,
)
.unwrap();
let wrong_kind = p
.add_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
"Pump water",
None,
)
.unwrap();
let opposite = if layer == Layer::Oa {
Layer::Sa
} else {
Layer::Oa
};
let wrong_layer = p
.add_element(
if opposite == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
opposite,
"Other layer pump",
None,
)
.unwrap();
let mut state = start(&p, layer, Family::Machine, "Pump modes");
assert!(
state
.prepare_pending(&p)
.unwrap_err()
.contains("participant")
);
assert!(state.update(&p, Event::Owner(wrong_kind)).is_err());
assert!(state.update(&p, Event::Owner(wrong_layer)).is_err());
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(first_view)).unwrap();
let target = state.created().unwrap();
let expected_count = p.elements.len() + 1;
let result = roundtrip(p, &mut state);
assert_eq!(result.elements.len(), expected_count);
assert_eq!(result.elements[&target.behavior].parent, Some(owner));
assert_eq!(result.elements[&target.behavior].kind(), Kind::StateMachine);
assert_eq!(result.diagrams.len(), usize::from(first_view));
if let Some(view) = target.diagram {
let diagram = &result.diagrams[&view];
assert_eq!(
diagram.projection,
ProjectionKind::StateMachine {
machine: target.behavior
}
);
assert_eq!(diagram.nodes.len(), 1);
assert_eq!(
diagram.nodes.values().next().unwrap().element,
target.behavior
);
}
}
}
}
#[test]
fn cancel_and_illegal_family_layer_switches_never_create_model_objects() {
let p = Project::empty();
let mut state = start(&p, Layer::Oa, Family::Scenario, "Raw name");
state
.update(
&p,
Event::Viewpoint(ScenarioViewpoint::ParticipantInteraction),
)
.unwrap();
let snapshot = state.recovery_snapshot().unwrap();
assert!(state.update(&p, Event::Open(Layer::Sa)).is_err());
assert!(state.update(&p, Event::Choose(Family::Sequence)).is_err());
assert_eq!(state.recovery_snapshot().unwrap(), snapshot);
assert!(state.update(&p, Event::Cancel).unwrap().is_none());
assert!(!state.has_pending());
assert!(state.created().is_none());
assert!(state.prepare_pending(&p).is_err());
assert!(p.elements.is_empty());
assert!(p.diagrams.is_empty());
}
#[test]
fn recovery_preserves_exact_raw_inputs_reserved_ids_and_revalidates_stale_owner() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Plant", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, " \nRaw rejected name\0 ");
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(true)).unwrap();
state
.update(&p, Event::Search(Picker::Owner, " Plant ".into()))
.unwrap();
let raw = state.recovery_snapshot().unwrap().unwrap();
let mut restored = State::from_recovery(raw.clone()).unwrap();
assert_eq!(restored.recovery_snapshot().unwrap(), Some(raw.clone()));
assert_eq!(restored.created(), state.created());
assert_eq!(restored.pending_layer(), Some(Layer::Oa));
assert!(restored.prepare_pending(&p).is_err());
restored
.update(&p, Event::Name("Plant modes".into()))
.unwrap();
let command = restored.prepare_pending(&p).unwrap();
let again = State::from_recovery(restored.recovery_snapshot().unwrap().unwrap()).unwrap();
assert_eq!(again.prepare_pending(&p).unwrap(), command);
p.elements.remove(&owner);
let before = restored.recovery_snapshot().unwrap();
assert!(
restored
.prepare_pending(&p)
.unwrap_err()
.contains("participant")
);
assert_eq!(restored.recovery_snapshot().unwrap(), before);
let different = Project::empty();
assert!(
restored
.prepare_pending(&different)
.unwrap_err()
.contains("another document")
);
}
#[test]
fn rejected_source_transaction_keeps_draft_and_every_accepted_source_byte() {
let p = Project::empty();
let initial = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
let bytes: usize = initial.bundle().files.values().map(String::len).sum();
let limits = ProjectLimits {
model: language::ParseLimits {
max_source_bytes: bytes + 32,
..language::ParseLimits::UNLIMITED
},
..ProjectLimits::default()
};
let mut doc = Document::from_bundle(initial.bundle().clone(), limits).unwrap();
let mut state = start(
&p,
Layer::Oa,
Family::Sequence,
"A long exact name retained after the source budget refuses this new process",
);
let raw = state.recovery_snapshot().unwrap();
let command = state.prepare_pending(&p).unwrap();
assert!(doc.execute(command).is_err());
assert_eq!(doc.project(), &p);
assert_eq!(doc.bundle(), initial.bundle());
assert!(!doc.can_undo());
assert_eq!(state.recovery_snapshot().unwrap(), raw);
state.update(&p, Event::Cancel).unwrap();
}
#[test]
fn owner_search_is_name_ordered_filtered_and_page_sized_not_a_catalog() {
let mut p = Project::empty();
for index in (0..105).rev() {
p.add_element(Kind::Entity, Layer::Oa, &format!("Pump {index:03}"), None)
.unwrap();
}
p.add_element(Kind::System, Layer::Sa, "Pump 999", None)
.unwrap();
p.add_element(Kind::Activity, Layer::Oa, "Pump work", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
let all = choices(&p, Layer::Oa, Picker::Owner, "");
assert_eq!(all.len(), 105);
assert_eq!(all[0].name, "Pump 000");
state
.update(&p, Event::Page(Picker::Owner, usize::MAX))
.unwrap();
assert!(
state.view(&p).is_some(),
"stale pages are clamped before slicing"
);
state
.update(&p, Event::Search(Picker::Owner, "Pump 104".into()))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
let found = choices(&p, Layer::Oa, Picker::Owner, "Pump 104");
assert_eq!(found.len(), 1);
state.update(&p, Event::Owner(found[0].id)).unwrap();
assert_eq!(state.draft.as_ref().unwrap().owner, Some(found[0].id));
}
#[test]
fn repeated_owner_names_remain_distinguishable_by_searchable_parent_context_and_alias() {
let mut p = Project::empty();
let north = p
.add_element(Kind::Entity, Layer::Oa, "North station", None)
.unwrap();
let south = p
.add_element(Kind::Entity, Layer::Oa, "South station", None)
.unwrap();
let first = p
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(north))
.unwrap();
let second = p
.add_element(Kind::Entity, Layer::Oa, "Pump", Some(south))
.unwrap();
let label = reference_label(&p, second);
assert_ne!(reference_label(&p, first), label);
assert!(label.contains("South station") && label.contains("Pump"));
assert!(label.contains(&p.elements[&second].alias));
let found = choices(&p, Layer::Oa, Picker::Owner, "south station");
assert!(found.iter().any(|e| e.id == second));
assert!(!found.iter().any(|e| e.id == first));
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump states");
state.update(&p, Event::Owner(second)).unwrap();
let result = execute_batch(&p, &[state.prepare_pending(&p).unwrap()]).unwrap();
assert_eq!(
result.elements[&state.created().unwrap().behavior].parent,
Some(second)
);
}
#[test]
fn corrupted_recovery_and_reserved_identity_collision_refuse_without_repairing_raw_values() {
let p = Project::empty();
let state = start(&p, Layer::Sa, Family::Sequence, "Supply");
let raw = state.recovery_snapshot().unwrap().unwrap();
for (field, value) in [
("version", serde_json::json!(99)),
("unexpected", serde_json::json!(true)),
] {
let mut bad = raw.clone();
bad[field] = value;
assert!(State::from_recovery(bad).is_err());
}
let mut bad = raw;
bad["element"] = serde_json::json!(p.id);
let restored = State::from_recovery(bad.clone()).unwrap();
assert!(
restored
.prepare_pending(&p)
.unwrap_err()
.contains("identity")
);
assert_eq!(restored.recovery_snapshot().unwrap(), Some(bad));
}
#[test]
fn post_commit_selection_is_correlated_with_exact_created_behavior_and_view() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Pump", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Machine, "Pump modes");
state.update(&p, Event::Owner(owner)).unwrap();
state.update(&p, Event::FirstView(true)).unwrap();
let command = state.prepare_pending(&p).unwrap();
assert_eq!(state.created_by(&p, &command), state.created());
assert_eq!(
state.created_by(&p, &Command::Rename(owner, "Unrelated change".into())),
None
);
for edit in 0..4 {
let mut changed = command.clone();
let Command::Batch(ref mut commands) = changed else {
panic!("creation batch");
};
if edit < 3 {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("new behavior");
};
match edit {
0 => element.id = Id::new_v4(),
1 => element.name = "A different authored draft".into(),
_ => element.parent = None,
}
} else {
let Command::CreateDiagram(diagram) = &mut commands[1] else {
panic!("new view");
};
diagram.projection = ProjectionKind::StateMachine { machine: owner };
}
assert_eq!(state.created_by(&p, &changed), None);
}
let Command::Batch(commands) = command else {
panic!("creation batch");
};
assert_eq!(
state.created_by(&p, &commands[0]),
None,
"an opt-in view must be part of the actual committed batch"
);
}
#[test]
fn optional_context_collapses_without_losing_authored_reference_or_raw_search() {
let mut p = Project::empty();
let context = p
.add_element(Kind::Capability, Layer::Oa, "Water supply", None)
.unwrap();
let mut state = start(&p, Layer::Oa, Family::Scenario, "Supply nominal");
assert!(!state.draft.as_ref().unwrap().context_open);
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::OperationalActivity))
.unwrap();
state.update(&p, Event::ContextOptions(true)).unwrap();
state
.update(&p, Event::Search(Picker::Context, " Water ".into()))
.unwrap();
state.update(&p, Event::Context(Some(context))).unwrap();
let command = state.prepare_pending(&p).unwrap();
state.update(&p, Event::ContextOptions(false)).unwrap();
let restored = State::from_recovery(state.recovery_snapshot().unwrap().unwrap()).unwrap();
assert!(!restored.draft.as_ref().unwrap().context_open);
assert_eq!(
restored.draft.as_ref().unwrap().browsers[1].query,
" Water "
);
assert_eq!(restored.prepare_pending(&p).unwrap(), command);
assert_eq!(restored.created_by(&p, &command), restored.created());
}
#[test]
fn creation_selection_rejects_changed_scenario_body_and_post_creation_operations() {
let mut p = Project::empty();
let capability = p
.add_element(Kind::SystemCapability, Layer::Sa, "Supply", None)
.unwrap();
let mut state = start(&p, Layer::Sa, Family::Scenario, "Nominal functions");
state
.update(&p, Event::Viewpoint(ScenarioViewpoint::Functional))
.unwrap();
let command = state.prepare_pending(&p).unwrap();
assert_eq!(state.created_by(&p, &command), state.created());
for edit in 0..4 {
let mut altered = command.clone();
let Command::Batch(commands) = &mut altered else {
panic!("creation batch");
};
match edit {
0 => {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("scenario");
};
let Some(SharedBody::Scenario { viewpoint, .. }) = element.shared_mut() else {
panic!("scenario body");
};
*viewpoint = ScenarioViewpoint::BoundaryInterface;
}
1 => {
let Command::CreateElement(element) = &mut commands[0] else {
panic!("scenario");
};
let Some(SharedBody::Scenario { context, .. }) = element.shared_mut() else {
panic!("scenario body");
};
*context = Some(capability);
}
2 => commands.push(Command::Rename(
state.created().unwrap().behavior,
"Changed afterward".into(),
)),
_ => commands.push(Command::Delete(state.created().unwrap().behavior)),
}
assert!(
execute_batch(&p, std::slice::from_ref(&altered)).is_ok(),
"the alternate command is valid, but is not this authored creation"
);
assert_eq!(state.created_by(&p, &altered), None);
}
}
@@ -579,11 +579,11 @@ fn render<'a>(
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let item_path = format!("{path}/{index}");
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
if path == FACETS {
if let Some(current) = current_observation(draft, project, &item_path) {
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
if path == FACETS
&& let Some(current) = current_observation(draft, project, &item_path)
{
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
card = card.push(row![
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
+9 -4
View File
@@ -446,8 +446,8 @@ fn article(topic: Topic) -> Article {
"Describe a path through exchanges",
&[
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
"Select that behavior in the page's selector. Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
"Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
@@ -489,7 +489,7 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Create a scenario",
&[
"Open Behavior & scenarios, choose Scenario under Create element and add it at a legal root/owner. Select the scenario in the behavior selector.",
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
"Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.",
"Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
"Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
@@ -543,7 +543,8 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Build a state machine",
&[
"In Behavior & scenarios, create or select a State machine, then choose Create diagram. This places its existing regions and vertices in a saved view. + View creates another independent layout.",
"In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.",
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
@@ -898,6 +899,10 @@ fn article(topic: Topic) -> Article {
"A property draft is not yet the model",
"Apply changes validates the candidate. When it is rejected, correct the retained draft or use Revert. The canvas continues to show accepted values. Navigation that needs to apply an invalid draft may remain on the current record so your work is not lost. You can read this guide without applying or discarding that draft.",
),
explain(
"A new behavior is still a draft",
"Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.",
),
explain(
"Aliases and references",
"Stable source aliases must follow the language's identifier rules and avoid reserved words. Use a simple descriptive identifier such as assess_incident. Spaces belong in the displayed Name, not the alias. Reference pickers offer compatible model records; creating the required target first is often the missing step.",