Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b08d8016b |
+28
-48
@@ -847,11 +847,6 @@ fn legacy_allows(e: &Element, outgoing: bool) -> bool {
|
||||
matches!(e.shared(), Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, .. }) }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out })
|
||||
}
|
||||
fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mut Vec<Issue>) {
|
||||
// Every carried-item constraint is a subset check. An empty exchange cannot
|
||||
// violate one, so endpoint feasibility need not scan all interface roles.
|
||||
if r.body.items().is_empty() {
|
||||
return;
|
||||
}
|
||||
match port.shared() {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
@@ -1065,6 +1060,34 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
|
||||
"This boundary exchange retains unresolved generic port semantics.",
|
||||
);
|
||||
}
|
||||
for (port, peer) in [(a, b), (b, a)] {
|
||||
if !matches!(
|
||||
port_contract(port),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for required in p.relations.values().filter(|role| {
|
||||
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
|
||||
}) {
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& role.target() == required.target()
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
required.id,
|
||||
"unsatisfied_interface_requirement",
|
||||
"target",
|
||||
"The connected partner has not declared this required interface as provided.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RelationBody::PortAllocation { source, target } => {
|
||||
let (Some(component), Some(function)) =
|
||||
@@ -1144,49 +1167,6 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Whole-model advisory checks. Missing peer declarations are warnings, so they
|
||||
/// must not make endpoint feasibility scan the relation graph per candidate.
|
||||
pub(crate) fn validate_boundary_interface_requirements(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
let RelationBody::BoundaryExchange { source, target, .. } = &r.body else {
|
||||
return;
|
||||
};
|
||||
let (Some(a), Some(b)) = (p.elements.get(source), p.elements.get(target)) else {
|
||||
return;
|
||||
};
|
||||
for (port, peer) in [(a, b), (b, a)] {
|
||||
if !matches!(
|
||||
port_contract(port),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for required in p.relations.values().filter(|role| {
|
||||
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
|
||||
}) {
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& role.target() == required.target()
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
required.id,
|
||||
"unsatisfied_interface_requirement",
|
||||
"target",
|
||||
"The connected partner has not declared this required interface as provided.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
|
||||
else {
|
||||
|
||||
@@ -643,7 +643,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
}
|
||||
for (key, d) in &p.diagrams {
|
||||
@@ -2045,10 +2044,7 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
// identities per endpoint while retaining the general lookup semantics
|
||||
// for other record kinds and malformed map keys.
|
||||
if !p.elements.get(&id).is_some_and(|element| element.id == id) && !p.contains(id) {
|
||||
if !p.contains(id) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
|
||||
@@ -42,70 +42,13 @@ pub struct State {
|
||||
scenario: scenario::State,
|
||||
states: states::State,
|
||||
}
|
||||
const PENDING_DRAFT: &str = "Apply or cancel the current behavior form before starting another.";
|
||||
impl State {
|
||||
pub fn has_pending(&self) -> bool {
|
||||
self.pending_owner().is_some()
|
||||
}
|
||||
|
||||
pub fn pending_owner(&self) -> Option<Id> {
|
||||
self.topology
|
||||
.pending_owner()
|
||||
.or_else(|| self.scenario.pending_owner())
|
||||
.or_else(|| self.states.pending_owner())
|
||||
}
|
||||
|
||||
/// Prepare the active form without discarding inputs. The caller must execute
|
||||
/// this exact command and acknowledge only a successful document transaction.
|
||||
pub fn prepare_pending(&self, p: &Project) -> Result<Option<Command>, String> {
|
||||
if self.topology.pending_owner().is_some() {
|
||||
self.topology.prepare_pending(p).map(Some)
|
||||
} else if self.scenario.pending_owner().is_some() {
|
||||
self.scenario.prepare_pending(p).map(Some)
|
||||
} else if self.states.pending_owner().is_some() {
|
||||
self.states.prepare_pending(p).map(Some)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear the active form after its prepared command commits successfully.
|
||||
/// Rejected commands must leave the form available for repair and retry.
|
||||
pub fn acknowledge_commit(&mut self) {
|
||||
self.topology.acknowledge_commit();
|
||||
self.scenario.acknowledge_commit();
|
||||
self.states.acknowledge_commit();
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.has_pending()
|
||||
&& matches!(
|
||||
&event,
|
||||
Event::Topology(topology::Event::Start(..))
|
||||
| Event::Scenario(scenario::Event::Start(..))
|
||||
| Event::States(states::Event::Start(..) | states::Event::Edit(..))
|
||||
)
|
||||
{
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
let cancels_pending = match &event {
|
||||
Event::Topology(topology::Event::Cancel) => self.topology.pending_owner().is_some(),
|
||||
Event::Scenario(scenario::Event::Cancel) => self.scenario.pending_owner().is_some(),
|
||||
Event::States(states::Event::Cancel) => self.states.pending_owner().is_some(),
|
||||
_ => false,
|
||||
};
|
||||
if cancels_pending {
|
||||
self.acknowledge_commit();
|
||||
return Ok(None);
|
||||
}
|
||||
match event {
|
||||
Event::Topology(event) => self.topology.update(p, event),
|
||||
Event::Scenario(event) => self.scenario.update(p, event),
|
||||
Event::States(event) => self.states.update(p, event),
|
||||
Event::Submit => self
|
||||
.prepare_pending(p)?
|
||||
.map(Some)
|
||||
.ok_or_else(|| "Choose a behavior modeling tool.".into()),
|
||||
Event::Submit => self.topology.update(p, topology::Event::Submit),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -755,208 +698,6 @@ impl canvas::Program<Message> for Scene {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::{Document, DocumentError};
|
||||
use oa_core::source_project::{self, ProjectLimits};
|
||||
|
||||
#[test]
|
||||
fn one_behavior_form_survives_start_edit_and_unrelated_cancel_until_commit() {
|
||||
let p = Project::demo();
|
||||
let owner = |kind| p.elements.values().find(|e| e.kind() == kind).unwrap().id;
|
||||
let process = owner(Kind::Process);
|
||||
let scenario = owner(Kind::Scenario);
|
||||
let machine = owner(Kind::StateMachine);
|
||||
let transition = p
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.kind() == RelationKind::StateTransition)
|
||||
.unwrap()
|
||||
.id;
|
||||
let starts = [
|
||||
Event::Topology(topology::Event::Start(
|
||||
process,
|
||||
topology::Tool::Control,
|
||||
None,
|
||||
)),
|
||||
Event::Scenario(scenario::Event::Start(scenario, scenario::Tool::Fragment)),
|
||||
Event::States(states::Event::Start(machine, states::Tool::Region, machine)),
|
||||
];
|
||||
let names = [
|
||||
Event::Topology(topology::Event::Name("Keep this draft".into())),
|
||||
Event::Scenario(scenario::Event::Name("Keep this draft".into())),
|
||||
Event::States(states::Event::Name("Keep this draft".into())),
|
||||
];
|
||||
let cancels = [
|
||||
Event::Topology(topology::Event::Cancel),
|
||||
Event::Scenario(scenario::Event::Cancel),
|
||||
Event::States(states::Event::Cancel),
|
||||
];
|
||||
for (index, expected_owner) in [process, scenario, machine].into_iter().enumerate() {
|
||||
let mut state = State::default();
|
||||
assert!(!state.has_pending());
|
||||
assert!(state.prepare_pending(&p).unwrap().is_none());
|
||||
state.update(&p, starts[index].clone()).unwrap();
|
||||
state.update(&p, names[index].clone()).unwrap();
|
||||
if index == 1 {
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::Scenario(scenario::Event::Expression("approved".into())),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(state.pending_owner(), Some(expected_owner));
|
||||
for replacement in starts
|
||||
.iter()
|
||||
.cloned()
|
||||
.chain([Event::States(states::Event::Edit(transition))])
|
||||
{
|
||||
assert_eq!(state.update(&p, replacement).unwrap_err(), PENDING_DRAFT);
|
||||
assert_eq!(state.pending_owner(), Some(expected_owner));
|
||||
}
|
||||
state.update(&p, cancels[(index + 1) % 3].clone()).unwrap();
|
||||
assert!(state.has_pending(), "Another editor's cancel is stale");
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.has_pending(), "Preparation must retain the form");
|
||||
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
||||
assert!(expected.elements.values().any(|e| {
|
||||
!p.elements.contains_key(&e.id)
|
||||
&& e.name == "Keep this draft"
|
||||
&& e.parent == Some(expected_owner)
|
||||
}));
|
||||
let mut document = Document::from_project(p.clone(), ProjectLimits::default()).unwrap();
|
||||
document.execute(command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
state.acknowledge_commit();
|
||||
assert!(!state.has_pending());
|
||||
assert!(state.prepare_pending(document.project()).unwrap().is_none());
|
||||
assert_eq!(document.revision(), 1);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &p);
|
||||
assert!(!document.can_undo());
|
||||
|
||||
state.update(&p, starts[index].clone()).unwrap();
|
||||
state.update(&p, cancels[index].clone()).unwrap();
|
||||
assert!(!state.has_pending(), "Explicit cancel discards this form");
|
||||
state.update(&p, starts[(index + 1) % 3].clone()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_document_commit_retains_typed_transition_and_time_event_for_repair() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(owner))
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Sa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let source = p
|
||||
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
|
||||
.unwrap();
|
||||
let choice = |id| Choice {
|
||||
id,
|
||||
label: p.named(id).into(),
|
||||
};
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
states::Event::Start(machine, states::Tool::Transition, source),
|
||||
states::Event::Name("Transition".repeat(1024)),
|
||||
states::Event::Trigger(choice(work)),
|
||||
states::Event::Effect(choice(work)),
|
||||
states::Event::TriggerNotes("On a monitor result".into()),
|
||||
states::Event::Guard("connection unavailable".into()),
|
||||
states::Event::EffectNotes("Record the timeout".into()),
|
||||
states::Event::Timed(true),
|
||||
states::Event::TimeKind("At".into()),
|
||||
states::Event::Expression("12:30 UTC".into()),
|
||||
] {
|
||||
state.update(&p, Event::States(event)).unwrap();
|
||||
}
|
||||
assert!(
|
||||
state.prepare_pending(&p).is_err(),
|
||||
"A target is still missing"
|
||||
);
|
||||
assert_eq!(state.pending_owner(), Some(machine));
|
||||
state
|
||||
.update(&p, Event::States(states::Event::Target(choice(target))))
|
||||
.unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap().unwrap();
|
||||
let bundle = source_project::format_bundle(&p);
|
||||
let mut limits = ProjectLimits::default();
|
||||
limits.model.max_source_bytes =
|
||||
bundle.files.values().map(String::len).sum::<usize>() + 2000;
|
||||
let mut document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
assert!(matches!(
|
||||
document.execute(command),
|
||||
Err(DocumentError::Source(_))
|
||||
));
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(document.bundle(), &bundle);
|
||||
assert_eq!(document.revision(), 0);
|
||||
assert!(!document.can_undo());
|
||||
assert!(state.has_pending());
|
||||
|
||||
state
|
||||
.update(
|
||||
&p,
|
||||
Event::States(states::Event::Name("Wait for recovery".into())),
|
||||
)
|
||||
.unwrap();
|
||||
let command = state.prepare_pending(&p).unwrap().unwrap();
|
||||
let Command::Batch(commands) = &command else {
|
||||
panic!("Expected one atomic transition batch")
|
||||
};
|
||||
assert_eq!(commands.len(), 2);
|
||||
let Command::CreateElement(event) = &commands[0] else {
|
||||
panic!("Expected a typed time event")
|
||||
};
|
||||
assert!(
|
||||
matches!(event.shared(), Some(SharedBody::Event { body: EventBody::Time { kind: TimeEventKind::At, expression } }) if expression == "12:30 UTC")
|
||||
);
|
||||
let Command::CreateRelation(relation) = &commands[1] else {
|
||||
panic!("Expected the transition")
|
||||
};
|
||||
let RelationBody::StateTransition {
|
||||
triggers,
|
||||
effects,
|
||||
trigger,
|
||||
guard,
|
||||
effect,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(triggers, &[work, event.id]);
|
||||
assert_eq!(effects, &[work]);
|
||||
assert_eq!(trigger, "On a monitor result");
|
||||
assert_eq!(guard, "connection unavailable");
|
||||
assert_eq!(effect, "Record the timeout");
|
||||
let expected = execute_batch(&p, std::slice::from_ref(&command)).unwrap();
|
||||
document.execute(command).unwrap();
|
||||
assert_eq!(
|
||||
document.project(),
|
||||
&expected,
|
||||
"Commit the exact prepared identities"
|
||||
);
|
||||
state.acknowledge_commit();
|
||||
assert!(!state.has_pending());
|
||||
assert_eq!(document.revision(), 1);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &bundle);
|
||||
assert_eq!(document.project(), &p);
|
||||
assert!(!document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_process_and_chain_keep_their_ordered_exchange_scene() {
|
||||
let p = Project::demo();
|
||||
|
||||
@@ -103,28 +103,7 @@ struct Draft {
|
||||
values: BTreeMap<Id, String>,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.owner)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a scenario tool first.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Start(owner, tool) => {
|
||||
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
|
||||
@@ -148,8 +127,14 @@ impl State {
|
||||
values: BTreeMap::new(),
|
||||
});
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let d = self.draft.as_ref().ok_or("Choose a scenario tool first.")?;
|
||||
let command = d.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
other => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match other {
|
||||
|
||||
@@ -189,28 +189,7 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.machine)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a state modeling tool.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..) | Event::Edit(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Start(machine, tool, selected) => {
|
||||
let mut d = Draft::new(machine, tool);
|
||||
@@ -270,8 +249,17 @@ impl State {
|
||||
d.effect = effect.clone();
|
||||
self.draft = Some(d);
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a state modeling tool.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
@@ -822,7 +810,6 @@ mod tests {
|
||||
}
|
||||
let mut result =
|
||||
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
let r = result.relations.values_mut().next().unwrap();
|
||||
r.description = "Retain configuration".into();
|
||||
let (id, alias) = (r.id, r.alias.clone());
|
||||
@@ -859,7 +846,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let result =
|
||||
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
let region = result
|
||||
.children(ready)
|
||||
.find(|e| e.kind() == Kind::Region)
|
||||
|
||||
@@ -76,28 +76,7 @@ struct Draft {
|
||||
control: ControlKind,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.owner)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a contribution tool first.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
@@ -139,8 +118,19 @@ impl State {
|
||||
control: ControlKind::Choice,
|
||||
});
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a contribution tool first.")?;
|
||||
let command = d.command(p)?;
|
||||
// Validate before clearing the user's inputs. The ordinary command
|
||||
// handler remains responsible for the single undoable transaction.
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
other => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match other {
|
||||
@@ -1782,13 +1772,9 @@ mod tests {
|
||||
assert!(state.draft.is_some());
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_some());
|
||||
assert!(state.draft.is_none());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
assert_eq!(p.elements[&owner].steps().unwrap().len(), 2);
|
||||
let result = execute_batch(&p, &[command]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
assert!(state.draft.is_none());
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
}
|
||||
#[test]
|
||||
fn cycle_projection_terminates_and_retains_actual_links() {
|
||||
|
||||
Reference in New Issue
Block a user