Add named behavior creation and verified textual authoring examples

This commit is contained in:
2026-09-11 04:26:56 -07:00
committed by Codex
parent c4e1580bb0
commit 1516949567
55 changed files with 6734 additions and 34 deletions
+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);
}
}