Add named behavior creation and verified textual authoring examples
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user