diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 9c2f1c76..420095a5 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -46,7 +46,8 @@ pub struct Draft { pub inputs: BTreeMap, pub exact_strings: bool, context: Option, - message_timer_draft: Option, + pub semantic_groups: bool, + constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap)>, port_contract_drafts: BTreeMap, pub value_browsers: BTreeMap, } @@ -86,7 +87,8 @@ impl Draft { inputs: BTreeMap::new(), exact_strings: false, context: None, - message_timer_draft: None, + semantic_groups: false, + constructor_drafts: BTreeMap::new(), port_contract_drafts: BTreeMap::new(), value_browsers: BTreeMap::new(), }; @@ -250,27 +252,73 @@ impl Draft { self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned())); Ok(()) } - /// Message-kind selection preserves the timer's exact input/editor state - /// while its payload is temporarily hidden by another kind. - pub fn message_kind_variant(&mut self, path: &str, value: Value) -> Result<(), String> { - serde_json::from_value::(value.clone()).map_err(|e| e.to_string())?; + /// Preserve each selected constructor branch, including incomplete raw input + /// and editor state. No sibling fields are parsed or cleared by a switch. + pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> { let current = self .value .pointer(path) - .ok_or("Message kind no longer exists.")?; - if same_variant(current, &value) { + .ok_or("Property no longer exists.")? + .clone(); + if same_variant(¤t, &proposed) { return Ok(()); } - let timer_path = format!("{path}/Timer/expression"); - if let Some(input) = self.inputs.remove(&timer_path) { - self.message_timer_draft = Some(input); + let constructor = path.ends_with("/DataType/definition") + || path.ends_with("/Port/contract") + || path.ends_with("/Occurrence/body") + || path.ends_with("/Event/body") + || path.ends_with("/message_kind"); + if !constructor { + return self.shape_scoped(path, proposed); } - self.shape_scoped(path, value)?; - if self.inputs.contains_key(&timer_path) - && let Some(input) = self.message_timer_draft.take() + // Input/output direction changes carry the item declaration. Read those + // raw reference strings before stashing their editor buffers so an + // incomplete reference cannot silently fall back to its stored UUID. + let port_current = if path.ends_with("/Port/contract") { + self.materialize_scopes(&[path.to_owned()])? + .pointer(path) + .cloned() + } else { + None + }; + let tag = |value: &Value| { + value + .as_str() + .or_else(|| value.as_object()?.keys().next().map(String::as_str)) + .unwrap_or("") + .to_owned() + }; + let previous_key = (path.to_owned(), tag(¤t)); + let next_key = (path.to_owned(), tag(&proposed)); + let mut inputs = BTreeMap::new(); + for key in self + .inputs + .keys() + .filter(|key| within(key, path)) + .cloned() + .collect::>() { - self.inputs.insert(timer_path, input); + inputs.insert(key.clone(), self.inputs.remove(&key).unwrap()); } + self.constructor_drafts + .insert(previous_key, (current.clone(), inputs)); + let saved = self.constructor_drafts.remove(&next_key); + let (value, restored) = if let Some(saved) = saved { + saved + } else { + let value = if path.ends_with("/Port/contract") { + port_fields::switch_contract( + port_current.as_ref().unwrap_or(¤t), + proposed, + &mut BTreeMap::new(), + ) + } else { + proposed + }; + (value, BTreeMap::new()) + }; + self.shape_scoped(path, value)?; + self.inputs.extend(restored); Ok(()) } fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option) { @@ -1315,6 +1363,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> "layer" => &["Oa", "Sa"], "mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"], "storage" => &["Value", "Reference"], + "direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"], "direction" if path.contains("DataField") => { &["Unspecified", "In", "Out", "InOut", "Return"] } @@ -1571,7 +1620,7 @@ fn property<'a>( return view; } let label_view = || text(label.to_owned()).size(11).color(style::MUTED); - if path.ends_with("/Port/legacy") { + if path.ends_with("/Port/legacy") && !draft.semantic_groups { let mut content = column![].spacing(4); if !value.is_null() { content = content.push( @@ -1585,6 +1634,29 @@ fn property<'a>( } return content.into(); } + if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant { + let mut content = column![ + label_view(), + text( + "Retained generic association is part of this group. Change or clear it explicitly." + ) + .size(11) + ] + .spacing(5); + if value.is_null() { + content = content.push(button(text("Add generic association").size(11)).on_press( + Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})), + )); + } else { + content = content + .push(property(draft, project, value, path, label, true)) + .push( + button(text("Clear generic association").size(11)) + .on_press(Message::FieldShape(path.into(), Value::Null)), + ); + } + return content.into(); + } let enum_choices = if skip_variant { vec![] } else { @@ -1842,6 +1914,73 @@ fn property<'a>( mod tests { use super::*; + #[test] + fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() { + let mut p = Project::empty(); + let function = p + .add_element(Kind::Function, Layer::Sa, "Function", None) + .unwrap(); + let component = p + .add_element(Kind::System, Layer::Sa, "Component", None) + .unwrap(); + let item = p + .add_element(Kind::ExchangeItem, Layer::Sa, "Item", None) + .unwrap(); + let id = Id::new_v4(); + p.elements.insert( + id, + ports::make_port( + &p, + id, + function, + "Port", + PortContract::FunctionInput { + incoming_items: vec![item], + }, + ) + .unwrap(), + ); + let mut draft = Draft::from_project(&p, id).unwrap(); + draft.semantic_groups = true; + let path = "/body/Sa/Shared/Port/contract"; + let input = format!("{path}/FunctionInput/incoming_items/0"); + draft.input(&input, "incomplete-id".into()); + let cursor = draft.inputs[&input].editor.cursor(); + draft + .variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}})) + .unwrap(); + assert_eq!( + draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw, + "incomplete-id" + ); + assert!(serde_json::from_value::(draft.materialize().unwrap()).is_err()); + draft + .variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}})) + .unwrap(); + assert_eq!(draft.inputs[&input].raw, "incomplete-id"); + assert_eq!(draft.inputs[&input].editor.cursor(), cursor); + let choices = + port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap(); + assert!( + choices + .iter() + .any(|(_, v)| v.get("FunctionInput").is_some()) + ); + assert!(choices.iter().any(|(_, v)| *v == json!("Legacy"))); + assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some())); + let mut context = draft.value.clone(); + context["parent"] = json!(component); + draft.set_context(Some(context)); + let choices = + port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap(); + assert!(choices.iter().any(|(_, v)| v.get("Component").is_some())); + assert!( + !choices + .iter() + .any(|(_, v)| v.get("FunctionInput").is_some()) + ); + } + fn ids(draft: &Draft, project: &Project, path: &str) -> Vec { reference_ids(draft, project, path) } diff --git a/crates/oa-desktop/src/forms/port_fields.rs b/crates/oa-desktop/src/forms/port_fields.rs index 5dee2c9f..11c48368 100644 --- a/crates/oa-desktop/src/forms/port_fields.rs +++ b/crates/oa-desktop/src/forms/port_fields.rs @@ -45,7 +45,11 @@ pub(super) fn variants( let current = draft.context_value(); let sa = current.pointer("/body/Sa").is_some(); if !sa { - return Some(vec![]); + return Some(if draft.semantic_groups { + vec![("Legacy generic port".into(), json!("Legacy"))] + } else { + vec![] + }); } let owner = current .get("parent") @@ -69,6 +73,9 @@ pub(super) fn variants( json!({"Component":{"kind":"Standard","orientation":"Unset"}}), )); } + if draft.semantic_groups { + choices.push(("Legacy generic port".into(), json!("Legacy"))); + } return Some(choices); } if path.ends_with("/Port/contract/Component/kind") { diff --git a/crates/oa-desktop/src/semantic_review.rs b/crates/oa-desktop/src/semantic_review.rs index f59ca4cb..5cbfe560 100644 --- a/crates/oa-desktop/src/semantic_review.rs +++ b/crates/oa-desktop/src/semantic_review.rs @@ -8,11 +8,14 @@ use oa_core::{transitions::*, *}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; +mod constructors; mod message_kind; mod scalar; mod view; pub(crate) use view::summary; #[cfg(test)] +mod constructor_tests; +#[cfg(test)] mod message_kind_tests; #[cfg(test)] mod tests; @@ -57,9 +60,9 @@ pub struct State { pub decisions: BTreeMap, pub references: BTreeMap, pub draft: Option, - // Group editing cannot reshape the independently authored TimerExpression - // scalar draft. Both drafts survive decision changes until this trace closes. - pub message_draft: Option, + // Each closed constructor has an independent draft, including nested kinds. + // Scalar and sibling drafts survive decision and constructor changes. + pub group_drafts: BTreeMap, pub plan: Option, pub proposed: Option, pub error: Option, @@ -102,7 +105,7 @@ impl State { decisions: BTreeMap::new(), references: BTreeMap::new(), draft: None, - message_draft: None, + group_drafts: BTreeMap::new(), plan: None, proposed: None, error: None, @@ -135,12 +138,19 @@ impl State { self.draft = self .target(project) .and_then(|id| Draft::from_project(project, id)); - self.message_draft = self - .trace - .and_then(|id| project.traces.get(&id)) - .filter(|trace| message_kind::available(project, trace)) - .and_then(|_| self.target(project)) - .and_then(|id| Draft::from_project(project, id)); + self.group_drafts.clear(); + if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) { + for facet in constructors::selectors() { + if constructors::available(project, trace, &facet) + && let Some(mut draft) = self + .target(project) + .and_then(|id| Draft::from_project(project, id)) + { + draft.semantic_groups = true; + self.group_drafts.insert(facet, draft); + } + } + } if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) { self.references.insert(trace.oa, trace.sa); } @@ -200,19 +210,9 @@ impl State { Event::Shape(path, value) => { self.edit(project, &path, |d| d.shape_scoped(&path, value)) } - Event::Variant(path, value) => self.edit(project, &path, |d| { - if path.ends_with("/message_kind") { - d.message_kind_variant(&path, value) - } else if d - .value - .pointer(&path) - .is_some_and(|v| crate::forms::same_variant(v, &value)) - { - Ok(()) - } else { - d.shape_scoped(&path, value) - } - }), + Event::Variant(path, value) => { + self.edit(project, &path, |d| d.variant_scoped(&path, value)) + } Event::Array(path, index, direction) => { self.edit(project, &path, |d| d.array_scoped(&path, index, direction)) } @@ -297,21 +297,23 @@ impl State { if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) { return Err("Choose Set final value before editing this property.".into()); } - let root = self + let roots = self .target(project) - .and_then(|id| scalar::binding(project, id, facet)) - .ok_or("Selected scalar property is unavailable.")?; - if path != root && !path.starts_with(&format!("{root}/")) { + .and_then(|id| constructors::roots(project, id, facet)) + .ok_or("Selected property is unavailable.")?; + if !roots + .iter() + .any(|root| path == root || path.starts_with(&format!("{root}/"))) + { return Err("Only the selected review property can change in this draft.".into()); } - let draft = if message_kind::handles(facet) { - &mut self.message_draft + let draft = if scalar::grouped(facet) { + self.group_drafts.get_mut(facet) } else { - &mut self.draft + self.draft.as_mut() } - .as_mut() .ok_or("Choose an existing target.")?; - // The scoped view sends only descendants of the selected scalar property. + // The scoped view sends only the selected property or closed group roots. // Protected identity and top-level body/perspective controls are never exposed. if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" { return Err("Semantic candidates retain identity, alias and perspective.".into()); @@ -324,17 +326,16 @@ impl State { .and_then(|id| scalar::binding(project, id, facet)) .is_some() && (!scalar::grouped(facet) - || (message_kind::handles(facet) - && self.message_draft.is_some() + || (self.group_drafts.contains_key(facet) && self .trace .and_then(|id| project.traces.get(&id)) - .is_some_and(|trace| message_kind::available(project, trace)))) + .is_some_and(|trace| constructors::available(project, trace, facet)))) } fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> { - if message_kind::handles(facet) { - self.message_draft.as_ref() + if scalar::grouped(facet) { + self.group_drafts.get(facet) } else { self.draft.as_ref() } @@ -418,7 +419,7 @@ impl State { Ok(request) } - /// Compose selected scalar values for both the candidate and dependent form + /// Compose selected values for both the candidate and dependent form /// controls. Incomplete inputs retain their stored shape only for controls; /// request construction always requires every selected input to parse. fn combined_value(&self, project: &Project, form_context: bool) -> Result { @@ -446,61 +447,75 @@ impl State { } } map.insert(source, target); - let mut message_candidate = None; - for (facet, decision) in &self.decisions { + let original = final_value.clone(); + let mut group_candidates = vec![]; + // Outer groups precede nested groups and leaves. The core rejects actual + // overlapping replacement scopes; this order also supports first-equal + // discriminator acknowledgment together with explicit descendant values. + let mut decisions: Vec<_> = self.decisions.iter().collect(); + decisions.sort_by_key(|(facet, _)| match facet { + FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0, + facet if scalar::grouped(facet) => 1, + _ => 2, + }); + for (facet, decision) in decisions { if *decision == SemanticDecision::KeepTarget { continue; } - let path = scalar::binding(project, target, facet) + let roots = constructors::roots(project, target, facet) .ok_or("Selected property is not applicable to the target.")?; - let value = match decision { + let supplied = match decision { SemanticDecision::KeepTarget => continue, SemanticDecision::Candidate => { let draft = self .candidate_draft(facet) .ok_or("Target draft is unavailable.")?; - let value = match draft.materialize_scopes(std::slice::from_ref(&path)) { + match draft.materialize_scopes(&roots) { Ok(value) => value, Err(_) if form_context => draft.value.clone(), Err(error) => return Err(error), - }; - value - .pointer(&path) - .cloned() - .ok_or("Candidate property is no longer available.")? + } } - SemanticDecision::TakeSource if message_kind::handles(facet) => { - // First equal kinds acknowledge only the discriminator. - // An unselected Timer payload must stay at the destination. - message_kind::source_value( - project, - if self.can_acknowledge_constructor(project, facet) { - target - } else { - source - }, - )? + SemanticDecision::TakeSource if scalar::grouped(facet) => { + if self.can_acknowledge_constructor(project, facet) { + original.clone() + } else { + constructors::take_value(project, trace, self.direction, facet, &map)? + } } SemanticDecision::TakeSource => { let fact = capture_facet(project, trace.oa, trace.sa, facet)?; - scalar::source_value(&fact, self.direction.source_layer(), &map)? + let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?; + let mut supplied = original.clone(); + *supplied + .pointer_mut(&roots[0]) + .ok_or("Source property is unavailable.")? = value; + supplied } }; - if message_kind::handles(facet) && *decision == SemanticDecision::Candidate { - message_candidate = Some((path.clone(), value.clone())); + for path in roots { + let value = supplied + .pointer(&path) + .cloned() + .ok_or("Candidate property is no longer available.")?; + if scalar::grouped(facet) && *decision == SemanticDecision::Candidate { + group_candidates.push((path.clone(), value.clone())); + } + *final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value; } - *final_value - .pointer_mut(&path) - .ok_or("Final target property is unavailable.")? = value; } - if !form_context - && let Some((path, candidate)) = message_candidate - && let Ok(current) = message_kind::source_value(project, target) - && crate::forms::same_variant(&candidate, ¤t) - && candidate != current - && final_value.pointer(&path) != Some(&candidate) - { - return Err("The message-kind candidate and Timer Expression decision propose different timer values. Keep the kind or make their final values agree; both drafts are retained.".into()); + if !form_context { + for (path, authored) in group_candidates { + if !original + .pointer(&path) + .zip(final_value.pointer(&path)) + .is_some_and(|(original, final_value)| { + constructors::authored_agrees(original, &authored, final_value) + }) + { + return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into()); + } + } } Ok(final_value) } @@ -513,8 +528,8 @@ impl State { if let Some(draft) = &mut self.draft { draft.set_context(context.clone()); } - if let Some(draft) = &mut self.message_draft { - draft.set_context(context); + for draft in self.group_drafts.values_mut() { + draft.set_context(context.clone()); } let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else { self.error = Some( diff --git a/crates/oa-desktop/src/semantic_review/constructor_tests.rs b/crates/oa-desktop/src/semantic_review/constructor_tests.rs new file mode 100644 index 00000000..22082867 --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/constructor_tests.rs @@ -0,0 +1,1462 @@ +use super::{tests::*, *}; +use crate::{app::App, document::Document}; +use serde_json::json; + +fn pair(oa: SharedBody, sa: SharedBody) -> (Project, Id, Id, Id) { + let mut p = Project::empty(); + let mut ids = vec![]; + for (layer, body) in [(Layer::Oa, oa), (Layer::Sa, sa)] { + let kind = match body { + SharedBody::DataType { .. } => Kind::DataType, + SharedBody::Port { .. } => Kind::Port, + SharedBody::Occurrence { .. } => Kind::Occurrence, + SharedBody::Event { .. } => Kind::Event, + _ => unreachable!(), + }; + let owner = if kind == Kind::Occurrence { + Some( + p.add_element(Kind::Scenario, layer, "Scenario", None) + .unwrap(), + ) + } else if kind == Kind::Port { + Some( + p.add_element( + if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }, + layer, + "Owner", + None, + ) + .unwrap(), + ) + } else { + None + }; + let id = if kind == Kind::Port { + let id = Id::new_v4(); + let port = ports::make_port( + &p, + id, + owner.unwrap(), + "Reviewed port", + PortContract::Legacy, + ) + .unwrap(); + p.elements.insert(id, port); + id + } else { + p.add_element(kind, layer, "Reviewed object", owner) + .unwrap() + }; + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body; + if kind == Kind::Occurrence { + p.elements + .get_mut(&owner.unwrap()) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + } + ids.push(id); + } + let trace = add_trace(&mut p, ids[0], ids[1], "Constructor review"); + (p, ids[0], ids[1], trace) +} +fn data(definition: DataTypeDefinition, default: Option) -> SharedBody { + SharedBody::DataType { + definition, + default, + unit: "retain unit".into(), + } +} +fn occurrence(body: OccurrenceBody) -> SharedBody { + SharedBody::Occurrence { + body, + precondition: "retain precondition".into(), + postcondition: "retain postcondition".into(), + } +} +fn event(body: EventBody) -> SharedBody { + SharedBody::Event { body } +} +fn failure(s: &State) -> String { + s.error + .iter() + .chain(s.plan.iter().flat_map(|p| &p.conflicts)) + .cloned() + .collect::>() + .join("; ") +} +fn reviewed(s: &State) -> BTreeSet { + s.plan.as_ref().unwrap().reviews[0] + .acknowledged + .iter() + .cloned() + .collect() +} +fn history(p: &mut Project, trace: Id) { + let t = &p.traces[&trace]; + let baseline = TraceBaseline { + oa: p.properties(t.oa).unwrap(), + sa: p.properties(t.sa).unwrap(), + semantic: Some(SemanticBaseline { + facets: semantic_facets(p, t, &BTreeMap::new()) + .unwrap() + .into_iter() + .map(|f| f.current) + .collect(), + }), + }; + p.traces.get_mut(&trace).unwrap().baseline = Some(baseline); +} + +#[test] +fn data_group_candidates_cover_every_definition_and_companion_default_both_directions() { + let definitions = [ + (DataTypeDefinition::Boolean, Some(DataValue::Boolean(true))), + ( + DataTypeDefinition::Integer { + minimum: Some(-3), + maximum: Some(15), + }, + Some(DataValue::Integer(7)), + ), + ( + DataTypeDefinition::Real { + minimum: Some(-0.5), + maximum: Some(12.5), + }, + Some(DataValue::Real(2.25)), + ), + ( + DataTypeDefinition::String { + minimum_length: 1, + maximum_length: Some(12), + }, + Some(DataValue::String("raw text".into())), + ), + ( + DataTypeDefinition::Enumeration { + literals: vec!["green".into(), "red".into()], + }, + Some(DataValue::Enumeration("green".into())), + ), + (DataTypeDefinition::Record { supertype: None }, None), + ]; + for reverse in [false, true] { + for (definition, default) in &definitions { + let initial = if *definition == DataTypeDefinition::Boolean { + DataTypeDefinition::Integer { + minimum: None, + maximum: None, + } + } else { + DataTypeDefinition::Boolean + }; + let (p, oa, sa, _) = pair(data(initial.clone(), None), data(initial, None)); + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + let group = FacetSelector::Data(DataFacet::Constructor); + let mut app = App::with_project(p); + start(&mut app, source); + decide(&mut app, group.clone(), SemanticDecision::Candidate); + let paths = constructors::roots(app.project(), target, &group).unwrap(); + send( + &mut app, + Event::Variant(paths[0].clone(), json!(definition)), + ); + send(&mut app, Event::Shape(paths[1].clone(), json!(default))); + assert!( + state(&app).can_apply(), + "{definition:?}: {}", + failure(state(&app)) + ); + assert_eq!( + state(&app).proposed.as_ref().unwrap().elements[&target].shared(), + Some(&data(definition.clone(), default.clone())) + ); + let _ = state(&app).view(app.project()); + assert!(reviewed(state(&app)).contains(&FacetSelector::Data(DataFacet::Default))); + assert!(!reviewed(state(&app)).contains(&FacetSelector::Data(DataFacet::Unit))); + apply(&mut app); + assert_eq!( + app.project().elements[&target].shared(), + Some(&data(definition.clone(), default.clone())) + ); + } + } +} + +#[test] +fn group_take_reference_payload_and_scalar_candidate_match_core_and_one_document_batch() { + for reverse in [false, true] { + for family in ["Data", "Event", "Occurrence"] { + let source_body = match family { + "Data" => data(DataTypeDefinition::Record { supertype: None }, None), + "Event" => event(EventBody::Signal { items: vec![] }), + _ => occurrence(OccurrenceBody::State { + state: None, + lifeline: None, + }), + }; + let target_body = match family { + "Data" => data(DataTypeDefinition::Boolean, Some(DataValue::Boolean(false))), + "Event" => event(EventBody::Change { + expression: "old".into(), + }), + _ => occurrence(OccurrenceBody::Action { + action: None, + lifeline: None, + }), + }; + let (mut p, oa, sa, trace) = if reverse { + pair(target_body, source_body) + } else { + pair(source_body, target_body) + }; + let kind = match family { + "Data" => Kind::DataType, + "Event" => Kind::ExchangeItem, + _ => Kind::State, + }; + let mut refs = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let parent = if kind == Kind::State { + let owner = p + .add_element( + if layer == Layer::Oa { + Kind::Entity + } else { + Kind::System + }, + layer, + "State owner", + None, + ) + .unwrap(); + let machine = p + .add_element(Kind::StateMachine, layer, "Machine", Some(owner)) + .unwrap(); + Some( + p.add_element(Kind::Region, layer, "Region", Some(machine)) + .unwrap(), + ) + } else { + None + }; + let id = p + .add_element(kind, layer, "Referenced definition", parent) + .unwrap(); + if family == "Data" { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = + data(DataTypeDefinition::Record { supertype: None }, None); + } + refs.push(id); + } + let (source, target, source_ref, target_ref) = if reverse { + (sa, oa, refs[1], refs[0]) + } else { + (oa, sa, refs[0], refs[1]) + }; + match p.elements.get_mut(&source).unwrap().shared_mut().unwrap() { + SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + .. + } => *supertype = Some(source_ref), + SharedBody::Event { + body: EventBody::Signal { items }, + } => items.push(source_ref), + SharedBody::Occurrence { + body: OccurrenceBody::State { state, .. }, + .. + } => *state = Some(source_ref), + _ => unreachable!(), + } + let group = match family { + "Data" => FacetSelector::Data(DataFacet::Constructor), + "Event" => FacetSelector::Event(EventFacet::Constructor), + _ => FacetSelector::Occurrence(OccurrenceFacet::Constructor), + }; + let mut app = App::with_project(p); + let mut bundle = app.document.bundle().clone(); + bundle + .files + .get_mut(&bundle.entry) + .unwrap() + .insert_str(0, "// Preserve the constructor review rationale.\n"); + app.document.apply_bundle(bundle).unwrap(); + let before = app.document.snapshot(); + start(&mut app, source); + decide(&mut app, group.clone(), SemanticDecision::TakeSource); + assert!(!state(&app).can_apply()); + let oa_choice = view::choice(app.project(), refs[0]); + let sa_choice = view::choice(app.project(), refs[1]); + send(&mut app, Event::ReferenceOa(oa_choice)); + send(&mut app, Event::ReferenceSa(sa_choice)); + send(&mut app, Event::AddReference); + assert!( + state(&app).can_apply(), + "{family} reverse={reverse}: {}", + failure(state(&app)) + ); + let core_group = state(&app).proposed.as_ref().unwrap().elements[&target].clone(); + decide( + &mut app, + FacetSelector::Header(HeaderFacet::Description), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Input("/description".into(), source_ref.to_string()), + ); + assert!( + state(&app).can_apply(), + "{family} reverse={reverse}: {}", + failure(state(&app)) + ); + assert_eq!( + state(&app).proposed.as_ref().unwrap().elements[&target].shared(), + core_group.shared() + ); + assert!(oa_core::references::element_references(&core_group).contains(&target_ref)); + assert_eq!(app.document.bundle(), &before.bundle); + let expected = state(&app).proposed.clone().unwrap(); + apply(&mut app); + assert_eq!(app.project(), &expected); + assert_eq!( + app.project().elements[&source], + before.decoded.project.elements[&source] + ); + assert!( + app.document.bundle().files[&app.document.bundle().entry] + .starts_with("// Preserve the constructor review rationale.") + ); + assert_eq!( + Document::from_bundle( + app.document.bundle().clone(), + source_project::ProjectLimits::default() + ) + .unwrap() + .snapshot() + .decoded + .project, + expected + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + let _ = app.update(Message::Redo); + assert_eq!(app.project(), &expected); + assert!( + app.project().traces[&trace] + .baseline + .as_ref() + .unwrap() + .semantic + .is_some() + ); + } + } +} + +#[test] +fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_editor_buffers() { + let integer = DataTypeDefinition::Integer { + minimum: Some(1), + maximum: Some(10), + }; + let (p, oa, sa, _) = pair( + data(integer.clone(), None), + data(integer, Some(DataValue::Integer(5))), + ); + let group = FacetSelector::Data(DataFacet::Constructor); + let minimum = FacetSelector::Data(DataFacet::IntegerMinimum); + let mut app = App::with_project(p); + start(&mut app, oa); + let roots = constructors::roots(app.project(), sa, &group).unwrap(); + let min_path = format!("{}/Integer/minimum", roots[0]); + decide(&mut app, minimum.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Input(min_path.clone(), "-".into())); + decide(&mut app, minimum.clone(), SemanticDecision::KeepTarget); + decide(&mut app, group.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Input(min_path.clone(), "-draft".into())); + let cursor = state(&app).group_drafts[&group].inputs[&min_path] + .editor + .cursor(); + send(&mut app, Event::Variant(roots[0].clone(), json!("Boolean"))); + assert_eq!( + state(&app).group_drafts[&group].value.pointer(&roots[1]), + Some(&json!({"Integer":5})) + ); + assert!(!state(&app).can_apply()); // overlap and explicitly incompatible default retained + send( + &mut app, + Event::Variant( + roots[0].clone(), + json!({"Integer":{"minimum":null,"maximum":null}}), + ), + ); + assert_eq!( + state(&app).group_drafts[&group].inputs[&min_path].raw, + "-draft" + ); + assert_eq!( + state(&app).group_drafts[&group].inputs[&min_path] + .editor + .cursor(), + cursor + ); + assert_eq!( + state(&app).draft.as_ref().unwrap().inputs[&min_path].raw, + "-" + ); + send(&mut app, Event::Shape(min_path.clone(), Value::Null)); + send(&mut app, Event::Variant(roots[0].clone(), json!("Boolean"))); + send( + &mut app, + Event::Shape(roots[1].clone(), json!({"Boolean":true})), + ); + send(&mut app, Event::Decide(minimum, None)); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + for protected in ["/id", "/alias", "/body", "/parent", "/name"] { + send(&mut app, Event::Shape(protected.into(), Value::Null)); + assert!( + state(&app) + .error + .as_ref() + .unwrap() + .contains("Only the selected") + ); + } + assert_eq!( + state(&app).draft.as_ref().unwrap().inputs[&min_path].raw, + "-" + ); + send(&mut app, Event::Preview); + assert!(state(&app).can_apply()); +} + +#[test] +fn occurrence_and_event_candidate_variants_cover_closed_families_without_touching_headers() { + let occurrences = [ + OccurrenceBody::Action { + action: None, + lifeline: None, + }, + OccurrenceBody::Control { + kind: ControlKind::Choice, + }, + OccurrenceBody::SequenceReference { sequence: None }, + OccurrenceBody::ScenarioUse { + scenario: None, + bindings: vec![], + }, + OccurrenceBody::State { + state: None, + lifeline: None, + }, + OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Timer { + expression: "after 5s".into(), + }, + reply_to: None, + values: vec![], + }, + OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }, + OccurrenceBody::Branch { + guard: "condition".into(), + occurrences: vec![], + }, + ]; + let events = [ + EventBody::Signal { items: vec![] }, + EventBody::Time { + kind: TimeEventKind::At, + expression: "08:00".into(), + }, + EventBody::Change { + expression: "changed".into(), + }, + ]; + for reverse in [false, true] { + for body in occurrences + .iter() + .cloned() + .map(occurrence) + .chain(events.iter().cloned().map(event)) + { + let (group, initial) = if matches!(body, SharedBody::Occurrence { .. }) { + ( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + occurrence(OccurrenceBody::Action { + action: None, + lifeline: None, + }), + ) + } else { + ( + FacetSelector::Event(EventFacet::Constructor), + event(EventBody::Change { + expression: "original".into(), + }), + ) + }; + let initial = match &body { + SharedBody::Occurrence { + body: OccurrenceBody::Action { .. }, + .. + } => occurrence(OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }), + SharedBody::Event { + body: EventBody::Change { .. }, + } => event(EventBody::Signal { items: vec![] }), + _ => initial, + }; + let (mut p, oa, sa, _) = pair(initial.clone(), initial); + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + match &body { + SharedBody::Occurrence { + body: OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. }, + .. + } => { + for id in [oa, sa] { + let old = p.elements[&id].parent.unwrap(); + p.elements + .get_mut(&old) + .unwrap() + .steps_mut() + .unwrap() + .clear(); + let layer = p.elements[&id].layer(); + let owner = p + .add_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + "Sequence", + None, + ) + .unwrap(); + p.elements + .get_mut(&owner) + .unwrap() + .steps_mut() + .unwrap() + .push(id); + p.elements.get_mut(&id).unwrap().parent = Some(owner); + } + } + SharedBody::Occurrence { + body: OccurrenceBody::Branch { .. }, + .. + } => { + let owner = p.elements[&target].parent.unwrap(); + let layer = p.elements[&target].layer(); + let fragment = p + .add_element(Kind::Occurrence, layer, "Fragment", Some(owner)) + .unwrap(); + *p.elements.get_mut(&fragment).unwrap().shared_mut().unwrap() = + occurrence(OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![target], + }); + *p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() = vec![fragment]; + p.elements.get_mut(&target).unwrap().parent = Some(fragment); + *p.elements.get_mut(&target).unwrap().shared_mut().unwrap() = + occurrence(OccurrenceBody::Branch { + guard: "old".into(), + occurrences: vec![], + }); + } + _ => {} + } + let mut app = App::with_project(p); + start(&mut app, source); + decide(&mut app, group.clone(), SemanticDecision::Candidate); + let root = scalar::binding(app.project(), target, &group).unwrap(); + let payload = match &body { + SharedBody::Occurrence { body, .. } => json!(body), + SharedBody::Event { body } => json!(body), + _ => unreachable!(), + }; + send(&mut app, Event::Shape(root, payload)); + assert!( + state(&app).can_apply(), + "{body:?}: {}", + failure(state(&app)) + ); + assert_eq!( + state(&app).proposed.as_ref().unwrap().elements[&target].shared(), + Some(&body) + ); + let _ = state(&app).view(app.project()); + } + } +} + +#[test] +fn port_group_companions_typed_owner_context_and_direction_restrictions_are_explicit() { + let legacy = || SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }), + }; + let group = FacetSelector::Port(PortFacet::Constructor); + let (mut p, oa, sa, _) = pair(legacy(), legacy()); + let item = p + .add_element(Kind::ExchangeItem, Layer::Sa, "Item", None) + .unwrap(); + let interface = p + .add_element(Kind::Interface, Layer::Sa, "Interface", None) + .unwrap(); + let component = p + .add_element(Kind::System, Layer::Sa, "Component", None) + .unwrap(); + let mut app = App::with_project(p); + start(&mut app, oa); + decide(&mut app, group.clone(), SemanticDecision::Candidate); + let roots = constructors::roots(app.project(), sa, &group).unwrap(); + send( + &mut app, + Event::Shape(format!("{}/interface", roots[1]), json!(interface)), + ); + send( + &mut app, + Event::Variant( + roots[0].clone(), + json!({"FunctionInput":{"incoming_items":[]}}), + ), + ); + send( + &mut app, + Event::Append( + format!("{}/FunctionInput/incoming_items", roots[0]), + json!(item), + ), + ); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + send( + &mut app, + Event::Variant( + roots[0].clone(), + json!({"FunctionOutput":{"outgoing_items":[]}}), + ), + ); + assert_eq!( + state(&app).group_drafts[&group] + .value + .pointer(&format!("{}/FunctionOutput/outgoing_items", roots[0])), + Some(&json!([item])) + ); + assert_eq!( + state(&app).group_drafts[&group] + .value + .pointer(&format!("{}/interface", roots[1])), + Some(&json!(interface)) + ); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + decide( + &mut app, + FacetSelector::Header(HeaderFacet::Parent), + SemanticDecision::Candidate, + ); + send(&mut app, Event::Shape("/parent".into(), json!(component))); + send(&mut app, Event::Select(group.clone())); + send( + &mut app, + Event::Variant( + roots[0].clone(), + json!({"Component":{"kind":"Flow","orientation":"InOut"}}), + ), + ); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + let _ = state(&app).view(app.project()); + assert_eq!( + state(&app).group_drafts[&group].context_value()["parent"], + json!(component) + ); + send(&mut app, Event::Shape(roots[1].clone(), Value::Null)); + assert!(state(&app).can_apply()); + apply(&mut app); + assert!(matches!( + app.project().elements[&sa].shared(), + Some(SharedBody::Port { + contract: PortContract::Component { .. }, + legacy: None + }) + )); + start(&mut app, sa); + decide(&mut app, group.clone(), SemanticDecision::TakeSource); + assert!( + !state(&app).can_apply(), + "Typed SA ports must not be copied to OA." + ); + let _ = state(&app).view(app.project()); + assert_eq!(app.project().elements[&oa].shared(), Some(&legacy())); +} + +#[test] +fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retain_both_drafts() { + let original = occurrence(OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Timer { + expression: "initial".into(), + }, + reply_to: None, + values: vec![], + }); + let (p, oa, sa, _) = pair(original.clone(), original); + let outer = FacetSelector::Occurrence(OccurrenceFacet::Constructor); + let nested = message_kind::selector(); + let mut app = App::with_project(p); + start(&mut app, oa); + let path = scalar::binding(app.project(), sa, &outer).unwrap(); + let kind_path = scalar::binding(app.project(), sa, &nested).unwrap(); + let expression = format!("{kind_path}/Timer/expression"); + decide(&mut app, nested.clone(), SemanticDecision::Candidate); + send( + &mut app, + Event::Input(expression.clone(), "nested draft".into()), + ); + decide(&mut app, outer.clone(), SemanticDecision::Candidate); + send( + &mut app, + Event::Input(expression.clone(), "outer draft".into()), + ); + assert!(!state(&app).can_apply()); + assert!(failure(state(&app)).contains("different final values")); + send( + &mut app, + Event::Variant( + path.clone(), + json!({"Action":{"action":null,"lifeline":null}}), + ), + ); + assert!(!state(&app).can_apply()); + assert_eq!(state(&app).decisions.len(), 2); + send(&mut app, Event::Decide(nested.clone(), None)); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + send( + &mut app, + Event::Variant( + path.clone(), + json!({"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}), + ), + ); + assert_eq!( + state(&app).group_drafts[&outer].inputs[&expression].raw, + "outer draft" + ); + assert_eq!( + state(&app).group_drafts[&nested].inputs[&expression].raw, + "nested draft" + ); + assert!( + !state(&app).can_apply(), + "Unchanged kind cannot silently acknowledge edited payload." + ); + send( + &mut app, + Event::Variant(path, json!({"Action":{"action":null,"lifeline":null}})), + ); + assert!(state(&app).can_apply()); + let scope = reviewed(state(&app)); + assert!(scope.contains(&message_kind::selector())); + assert!(scope.contains(&message_kind::timer_selector())); + assert!(!scope.contains(&FacetSelector::Occurrence(OccurrenceFacet::Precondition))); + assert!(!scope.contains(&FacetSelector::Occurrence(OccurrenceFacet::Postcondition))); +} + +#[test] +fn historical_event_keep_retires_only_obsolete_branch_and_unchanged_kind_acknowledges_no_payload() { + let time = || { + event(EventBody::Time { + kind: TimeEventKind::After, + expression: "5 seconds".into(), + }) + }; + let (mut p, oa, sa, trace) = pair(time(), time()); + history(&mut p, trace); + for id in [oa, sa] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = event(EventBody::Change { + expression: "changed".into(), + }); + } + let group = FacetSelector::Event(EventFacet::Constructor); + let mut app = App::with_project(p); + start(&mut app, oa); + decide(&mut app, group.clone(), SemanticDecision::KeepTarget); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + let preview = &state(&app).plan.as_ref().unwrap().reviews[0]; + assert_eq!( + preview.retired.iter().cloned().collect::>(), + BTreeSet::from([ + FacetSelector::Event(EventFacet::TimeKind), + FacetSelector::Event(EventFacet::TimeExpression) + ]) + ); + assert_eq!( + reviewed(state(&app)), + BTreeSet::from([ + group.clone(), + FacetSelector::Event(EventFacet::ChangeExpression) + ]) + ); + apply(&mut app); + start(&mut app, oa); + decide(&mut app, group.clone(), SemanticDecision::KeepTarget); + assert!(!state(&app).can_apply()); + let (p, oa, sa, _) = pair( + time(), + event(EventBody::Time { + kind: TimeEventKind::At, + expression: "9:00".into(), + }), + ); + let mut app = App::with_project(p); + start(&mut app, oa); + decide(&mut app, group.clone(), SemanticDecision::TakeSource); + decide( + &mut app, + FacetSelector::Header(HeaderFacet::Name), + SemanticDecision::Candidate, + ); + send(&mut app, Event::Input("/name".into(), "Kind only".into())); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + assert_eq!( + reviewed(state(&app)), + BTreeSet::from([group, FacetSelector::Header(HeaderFacet::Name)]) + ); + assert_eq!( + state(&app).proposed.as_ref().unwrap().elements[&sa].shared(), + app.project().elements[&sa].shared() + ); +} + +#[test] +fn existing_fragment_and_branch_group_candidates_edit_complete_owned_order_without_deletion() { + for reverse in [false, true] { + for fragment in [false, true] { + let source_body = occurrence(OccurrenceBody::Action { + action: None, + lifeline: None, + }); + let target_body = occurrence(OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![], + }); + let (mut p, oa, sa, _) = if reverse { + pair(target_body, source_body) + } else { + pair(source_body, target_body) + }; + let (source, mut target) = if reverse { (sa, oa) } else { (oa, sa) }; + if !fragment { + let branch = p + .add_element( + Kind::Occurrence, + p.elements[&target].layer(), + "Branch", + Some(target), + ) + .unwrap(); + *p.elements.get_mut(&branch).unwrap().shared_mut().unwrap() = + occurrence(OccurrenceBody::Branch { + guard: "old guard".into(), + occurrences: vec![], + }); + *p.elements.get_mut(&target).unwrap().shared_mut().unwrap() = + occurrence(OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: vec![branch], + }); + let t = p.traces.values_mut().next().unwrap(); + if reverse { + t.oa = branch; + } else { + t.sa = branch; + } + target = branch; + } + let mut children = vec![]; + for name in ["First", "Second"] { + let child = p + .add_element( + Kind::Occurrence, + p.elements[&target].layer(), + name, + Some(target), + ) + .unwrap(); + *p.elements.get_mut(&child).unwrap().shared_mut().unwrap() = if fragment { + occurrence(OccurrenceBody::Branch { + guard: name.into(), + occurrences: vec![], + }) + } else { + occurrence(OccurrenceBody::Action { + action: None, + lifeline: None, + }) + }; + children.push(child); + } + let (payload, suffix) = if fragment { + ( + occurrence(OccurrenceBody::Fragment { + operator: FragmentOperator::Alternative, + branches: children.clone(), + }), + "/Fragment/branches", + ) + } else { + ( + occurrence(OccurrenceBody::Branch { + guard: "old guard".into(), + occurrences: children.clone(), + }), + "/Branch/occurrences", + ) + }; + *p.elements.get_mut(&target).unwrap().shared_mut().unwrap() = payload; + let original_children = children + .iter() + .map(|id| p.elements[id].clone()) + .collect::>(); + let mut app = App::with_project(p); + start(&mut app, source); + let group = FacetSelector::Occurrence(OccurrenceFacet::Constructor); + let root = scalar::binding(app.project(), target, &group).unwrap(); + decide(&mut app, group, SemanticDecision::Candidate); + send( + &mut app, + Event::Array(format!("{root}{suffix}"), Some(1), -1), + ); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + let result = state(&app).proposed.as_ref().unwrap(); + let value = json!(result.elements[&target]); + assert_eq!( + value.pointer(&format!("{root}{suffix}")), + Some(&json!([children[1], children[0]])) + ); + let expected_order = FacetSelector::Order(if fragment { + OrderedCollection::FragmentBranches + } else { + OrderedCollection::Steps + }); + assert!(reviewed(state(&app)).contains(&expected_order)); + assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty()); + let _ = state(&app).view(app.project()); + apply(&mut app); + assert_eq!( + children + .iter() + .map(|id| app.project().elements[id].clone()) + .collect::>(), + original_children + ); + } + } +} + +#[test] +fn scenario_use_group_takes_typed_binding_references_and_preserves_source_text_both_directions() { + for reverse in [false, true] { + let use_body = occurrence(OccurrenceBody::ScenarioUse { + scenario: None, + bindings: vec![], + }); + let action = occurrence(OccurrenceBody::Action { + action: None, + lifeline: None, + }); + let (mut p, oa, sa, _) = if reverse { + pair(action, use_body) + } else { + pair(use_body, action) + }; + let mut refs = vec![]; + for (layer, id) in [(Layer::Oa, oa), (Layer::Sa, sa)] { + let scenario = p + .add_element(Kind::Scenario, layer, "Reused scenario", None) + .unwrap(); + let referenced = p + .add_element(Kind::Lifeline, layer, "Referenced lifeline", Some(scenario)) + .unwrap(); + let local = p + .add_element( + Kind::Lifeline, + layer, + "Local lifeline", + p.elements[&id].parent, + ) + .unwrap(); + refs.push([scenario, referenced, local]); + } + let (source, target, side) = if reverse { (sa, oa, 1) } else { (oa, sa, 0) }; + *p.elements.get_mut(&source).unwrap().shared_mut().unwrap() = + occurrence(OccurrenceBody::ScenarioUse { + scenario: Some(refs[side][0]), + bindings: vec![LifelineBinding { + referenced: refs[side][1], + local: refs[side][2], + }], + }); + let mut s = State::new(&p, source).unwrap(); + for (oa, sa) in refs[0].iter().zip(&refs[1]) { + s.references.insert(*oa, *sa); + } + s.update( + &p, + Event::Decide( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + Some(SemanticDecision::TakeSource), + ), + ); + assert!(s.can_apply(), "{}", failure(&s)); + let expected = s.proposed.as_ref().unwrap().elements[&target] + .shared() + .unwrap() + .clone(); + s.update( + &p, + Event::Decide( + FacetSelector::Header(HeaderFacet::Description), + Some(SemanticDecision::Candidate), + ), + ); + s.update( + &p, + Event::Select(FacetSelector::Header(HeaderFacet::Description)), + ); + s.update( + &p, + Event::Input("/description".into(), refs[side][1].to_string()), + ); + assert!(s.can_apply(), "{}", failure(&s)); + assert_eq!( + s.proposed.as_ref().unwrap().elements[&target].shared(), + Some(&expected) + ); + assert_eq!( + expected, + occurrence(OccurrenceBody::ScenarioUse { + scenario: Some(refs[1 - side][0]), + bindings: vec![LifelineBinding { + referenced: refs[1 - side][1], + local: refs[1 - side][2] + }] + }) + ); + } +} + +#[test] +fn reverse_existing_typed_port_reviews_keep_or_candidate_legacy_without_permitting_typed_oa_write() +{ + for decision in [SemanticDecision::KeepTarget, SemanticDecision::Candidate] { + let legacy = SharedBody::Port { + contract: PortContract::Legacy, + legacy: Some(LegacyPortContract { + direction: PortDirection::Out, + interface: None, + }), + }; + let typed = SharedBody::Port { + contract: PortContract::FunctionOutput { + outgoing_items: vec![], + }, + legacy: None, + }; + let (p, oa, sa, _) = pair(legacy.clone(), typed); + let mut app = App::with_project(p); + let before = app.document.snapshot(); + start(&mut app, sa); + let group = FacetSelector::Port(PortFacet::Constructor); + decide(&mut app, group.clone(), SemanticDecision::TakeSource); + assert!(!state(&app).can_apply()); + assert_eq!(app.document.bundle(), &before.bundle); + decide(&mut app, group, decision); + let mut expected = legacy.clone(); + if decision == SemanticDecision::Candidate { + send( + &mut app, + Event::Shape("/body/Oa/Shared/Port/legacy/direction".into(), json!("In")), + ); + if let SharedBody::Port { + legacy: Some(association), + .. + } = &mut expected + { + association.direction = PortDirection::In; + } + } + assert!( + state(&app).can_apply(), + "{decision:?}: {}", + failure(state(&app)) + ); + assert_eq!( + state(&app).proposed.as_ref().unwrap().elements[&oa].shared(), + Some(&expected) + ); + assert!(reviewed(state(&app)).contains(&FacetSelector::Port(PortFacet::LegacyDirection))); + apply(&mut app); + assert_eq!(app.project().elements[&oa].shared(), Some(&expected)); + assert_eq!( + app.project().elements[&sa], + before.decoded.project.elements[&sa] + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + } +} + +#[test] +fn historical_same_kind_take_preserves_target_only_literals_when_composed_with_candidate() { + for reverse in [false, true] { + let source = data(DataTypeDefinition::Boolean, None); + let target = data( + DataTypeDefinition::Enumeration { + literals: vec!["local".into()], + }, + Some(DataValue::Enumeration("local".into())), + ); + let (mut p, oa, sa, trace) = if reverse { + pair(target, source) + } else { + pair(source, target) + }; + history(&mut p, trace); + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + *p.elements.get_mut(&source).unwrap().shared_mut().unwrap() = data( + DataTypeDefinition::Enumeration { + literals: vec!["incoming".into()], + }, + Some(DataValue::Enumeration("incoming".into())), + ); + let mut app = App::with_project(p); + start(&mut app, source); + decide( + &mut app, + FacetSelector::Data(DataFacet::Constructor), + SemanticDecision::TakeSource, + ); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + let core_payload = state(&app).proposed.as_ref().unwrap().elements[&target] + .shared() + .unwrap() + .clone(); + decide( + &mut app, + FacetSelector::Data(DataFacet::Unit), + SemanticDecision::Candidate, + ); + let unit = + scalar::binding(app.project(), target, &FacetSelector::Data(DataFacet::Unit)).unwrap(); + send(&mut app, Event::Input(unit, "final unit".into())); + assert!(state(&app).can_apply(), "{}", failure(state(&app))); + let SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { literals }, + .. + } = core_payload + else { + panic!() + }; + assert_eq!(literals, ["incoming", "local"]); + apply(&mut app); + assert!( + matches!(app.project().elements[&target].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Enumeration { literals }, unit, .. }) if literals == &["incoming", "local"] && unit == "final unit") + ); + } +} + +#[test] +fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_and_direction() { + let data_bodies = vec![ + data(DataTypeDefinition::Boolean, Some(DataValue::Boolean(true))), + data( + DataTypeDefinition::Integer { + minimum: Some(-5), + maximum: Some(20), + }, + Some(DataValue::Integer(3)), + ), + data( + DataTypeDefinition::Real { + minimum: None, + maximum: Some(4.5), + }, + Some(DataValue::Real(1.25)), + ), + data( + DataTypeDefinition::String { + minimum_length: 0, + maximum_length: Some(20), + }, + Some(DataValue::String("source text".into())), + ), + data( + DataTypeDefinition::Enumeration { + literals: vec!["a".into(), "b".into()], + }, + Some(DataValue::Enumeration("a".into())), + ), + data(DataTypeDefinition::Record { supertype: None }, None), + ]; + let event_bodies = vec![ + event(EventBody::Signal { items: vec![] }), + event(EventBody::Time { + kind: TimeEventKind::After, + expression: "after 12 s".into(), + }), + event(EventBody::Change { + expression: "mode changed".into(), + }), + ]; + for (group, bodies) in [ + (FacetSelector::Data(DataFacet::Constructor), data_bodies), + (FacetSelector::Event(EventFacet::Constructor), event_bodies), + ] { + for reverse in [false, true] { + for source_body in &bodies { + for target_body in &bodies { + let (p, oa, sa, _) = if reverse { + pair(target_body.clone(), source_body.clone()) + } else { + pair(source_body.clone(), target_body.clone()) + }; + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + let mut s = State::new(&p, source).unwrap(); + s.update( + &p, + Event::Decide(group.clone(), Some(SemanticDecision::TakeSource)), + ); + assert!( + s.can_apply(), + "{source_body:?} -> {target_body:?}, reverse={reverse}: {}", + failure(&s) + ); + let expected = s.proposed.as_ref().unwrap().elements[&target] + .shared() + .unwrap() + .clone(); + let expected_review = s.plan.as_ref().unwrap().reviews[0].clone(); + s.update( + &p, + Event::Decide( + FacetSelector::Header(HeaderFacet::Description), + Some(SemanticDecision::Candidate), + ), + ); + s.update( + &p, + Event::Select(FacetSelector::Header(HeaderFacet::Description)), + ); + s.update( + &p, + Event::Input("/description".into(), "composed candidate".into()), + ); + assert!( + s.can_apply(), + "{source_body:?} -> {target_body:?}, reverse={reverse}: {}", + failure(&s) + ); + assert_eq!( + s.proposed.as_ref().unwrap().elements[&target].shared(), + Some(&expected) + ); + let reviewed = reviewed(&s); + assert!( + expected_review + .acknowledged + .iter() + .all(|f| reviewed.contains(f)) + ); + assert_eq!( + s.plan.as_ref().unwrap().reviews[0].retired, + expected_review.retired + ); + } + } + } + } +} + +#[test] +fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_expression_text() { + for reverse in [false, true] { + let message = occurrence(OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + }); + let action = occurrence(OccurrenceBody::Action { + action: None, + lifeline: None, + }); + let (mut p, oa, sa, _) = if reverse { + pair(action, message) + } else { + pair(message, action) + }; + let mut refs = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let work = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let from = p.add_element(work, layer, "From", None).unwrap(); + let to = p.add_element(work, layer, "To", None).unwrap(); + let item = p + .add_element(Kind::ExchangeItem, layer, "Carried item", None) + .unwrap(); + let record = p + .add_element(Kind::DataType, layer, "Record", None) + .unwrap(); + *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = + data(DataTypeDefinition::Record { supertype: None }, None); + let field = p + .add_element(Kind::DataField, layer, "Record-valued field", Some(item)) + .unwrap(); + if let Some(SharedBody::DataField { type_ref, .. }) = + p.elements.get_mut(&field).unwrap().shared_mut() + { + *type_ref = TypeRef::Named(record); + } + let leaf = p + .add_element(Kind::DataField, layer, "Qualified leaf", Some(record)) + .unwrap(); + let exchange = p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "Carrier", + from, + to, + ); + let ports = match &mut p.relations.get_mut(&exchange).unwrap().body { + RelationBody::Interaction { items, .. } => { + items.push(item); + None + } + RelationBody::FunctionalExchange { + items, + source_port, + target_port, + .. + } => { + items.push(item); + Some((*source_port, *target_port)) + } + _ => unreachable!(), + }; + if let Some((output, input)) = ports { + if let Some(SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + }) = p.elements.get_mut(&output).unwrap().shared_mut() + { + outgoing_items.push(item); + } + if let Some(SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + }) = p.elements.get_mut(&input).unwrap().shared_mut() + { + incoming_items.push(item); + } + } + refs.push([exchange, field, leaf]); + } + let (source, target, side) = if reverse { (sa, oa, 1) } else { (oa, sa, 0) }; + let expression = format!(" {}\n exact authored expression ", refs[side][1]); + if let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange, values, .. + }, + .. + }) = p.elements.get_mut(&source).unwrap().shared_mut() + { + *exchange = Some(refs[side][0]); + values.push(MessageValue { + path: vec![refs[side][1]], + field: refs[side][2], + expression: expression.clone(), + }); + } + let mut s = State::new(&p, source).unwrap(); + for (oa, sa) in refs[0].iter().zip(&refs[1]) { + s.references.insert(*oa, *sa); + } + s.update( + &p, + Event::Decide( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + Some(SemanticDecision::TakeSource), + ), + ); + assert!(s.can_apply(), "reverse={reverse}: {}", failure(&s)); + s.update( + &p, + Event::Decide( + FacetSelector::Header(HeaderFacet::Description), + Some(SemanticDecision::Candidate), + ), + ); + s.update( + &p, + Event::Select(FacetSelector::Header(HeaderFacet::Description)), + ); + s.update( + &p, + Event::Input("/description".into(), "annotation reviewed".into()), + ); + assert!(s.can_apply(), "reverse={reverse}: {}", failure(&s)); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = s.proposed.as_ref().unwrap().elements[&target].shared() + else { + panic!() + }; + assert_eq!( + values, + &[MessageValue { + path: vec![refs[1 - side][1]], + field: refs[1 - side][2], + expression + }] + ); + assert_eq!( + s.proposed.as_ref().unwrap().elements[&source], + p.elements[&source] + ); + } +} diff --git a/crates/oa-desktop/src/semantic_review/constructors.rs b/crates/oa-desktop/src/semantic_review/constructors.rs new file mode 100644 index 00000000..58dcca0b --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/constructors.rs @@ -0,0 +1,327 @@ +//! Closed constructor payload bindings. The core owns decisions and review scope; +//! this adapter supplies complete target records for its Candidate agreement check. +use super::{scalar, *}; + +pub(super) fn selectors() -> [FacetSelector; 5] { + [ + FacetSelector::Data(DataFacet::Constructor), + FacetSelector::Port(PortFacet::Constructor), + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + FacetSelector::Occurrence(OccurrenceFacet::MessageKind), + FacetSelector::Event(EventFacet::Constructor), + ] +} + +pub(super) fn roots(project: &Project, target: Id, facet: &FacetSelector) -> Option> { + let root = scalar::binding(project, target, facet)?; + let mut roots = vec![root.clone()]; + match facet { + FacetSelector::Data(DataFacet::Constructor) => { + roots.push(format!("{}/default", root.strip_suffix("/definition")?)); + } + FacetSelector::Port(PortFacet::Constructor) => { + roots.push(format!("{}/legacy", root.strip_suffix("/contract")?)); + } + _ => {} + } + Some(roots) +} + +pub(super) fn available(project: &Project, trace: &Trace, facet: &FacetSelector) -> bool { + roots(project, trace.oa, facet).is_some() && roots(project, trace.sa, facet).is_some() +} + +/// Used for explanation and explicit overlap controls, never for acknowledgment. +pub(super) fn contains(group: &FacetSelector, facet: &FacetSelector) -> bool { + use FacetSelector as F; + match group { + F::Data(DataFacet::Constructor) => { + matches!(facet, F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_)) + } + F::Port(PortFacet::Constructor) => matches!( + facet, + F::Port(_) + | F::Member { + collection: ReferenceCollection::FunctionInputItems + | ReferenceCollection::FunctionOutputItems, + .. + } + ), + F::Occurrence(OccurrenceFacet::Constructor) => { + matches!(facet, F::Occurrence(f) if !matches!(f, OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition)) + || matches!( + facet, + F::Annotation(_) + | F::Binding(_) + | F::Member { + collection: ReferenceCollection::Steps + | ReferenceCollection::FragmentBranches, + .. + } + | F::Order(_) + ) + } + F::Occurrence(OccurrenceFacet::MessageKind) => matches!( + facet, + F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression) + ), + F::Event(EventFacet::Constructor) => matches!( + facet, + F::Event(_) + | F::Member { + collection: ReferenceCollection::SignalItems, + .. + } + ), + _ => false, + } +} + +pub(super) fn description(facet: &FacetSelector) -> &'static str { + match facet { + FacetSelector::Data(_) => { + "Definition, bounds or literal members, record supertype and default. Unit stays independent." + } + FacetSelector::Port(_) => { + "Contract, item members and retained generic association. Owner stays independent." + } + FacetSelector::Occurrence(OccurrenceFacet::Constructor) => { + "Occurrence body, including references, message annotations, bindings and branch or step order. Pre/postconditions stay independent." + } + FacetSelector::Occurrence(_) => { + "Message kind and Timer Expression. Exchange references and annotations stay independent." + } + FacetSelector::Event(_) => { + "Event body, including signal items or its complete time/change expression." + } + _ => "", + } +} + +fn chosen(keys: &KeyPair, layer: Layer) -> Option<&T> { + match layer { + Layer::Oa => keys.oa.as_ref(), + Layer::Sa => keys.sa.as_ref(), + } +} + +// The source payload is mapped by the core's typed visitor, never by replacing +// UUID-looking strings. For a historical same-kind Take, retain only current +// target-present/source-absent rows with no prior source-present observation. The final core planner +// checks this assembled record against its own independent expected result. +pub(super) fn take_value( + project: &Project, + trace: &Trace, + direction: TransitionDirection, + facet: &FacetSelector, + map: &BTreeMap, +) -> Result { + let (source, target) = if direction == TransitionDirection::OaToSa { + (trace.oa, trace.sa) + } else { + (trace.sa, trace.oa) + }; + let mut element = project + .elements + .get(&source) + .cloned() + .ok_or("Missing constructor source.")?; + if !message_kind::handles(facet) { + element.parent = None; + let mut missing = BTreeSet::new(); + oa_core::references::visit_element_refs(&mut element, |id| { + if let Some(mapped) = map.get(id) { + *id = *mapped; + } else { + missing.insert(*id); + } + }); + if !missing.is_empty() { + return Err(format!( + "Missing explicit reference correspondence: {missing:?}" + )); + } + } + let supplied = serde_json::to_value(element).map_err(|e| e.to_string())?; + let mut value = Draft::from_project(project, target) + .ok_or("Missing target draft.")? + .value; + for (from, to) in roots(project, source, facet) + .ok_or("Source group is unavailable.")? + .iter() + .zip(roots(project, target, facet).ok_or("Target group is unavailable.")?) + { + *value + .pointer_mut(&to) + .ok_or("Target group payload is unavailable.")? = supplied + .pointer(from) + .cloned() + .ok_or("Source group payload is unavailable.")?; + } + let current = capture_facet(project, trace.oa, trace.sa, facet)?; + if scalar::observation(¤t, Layer::Oa) != scalar::observation(¤t, Layer::Sa) { + return Ok(value); + } + let references = if direction == TransitionDirection::OaToSa { + map.clone() + } else { + map.iter().map(|(sa, oa)| (*oa, *sa)).collect() + }; + let old = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref()); + for fact in semantic_facets(project, trace, &references)? { + if !contains(facet, &fact.selector) { + continue; + } + let Some((path, key)) = + target_row(project, target, &fact.selector, direction.target_layer()) + else { + continue; + }; + if row_present(&fact.current, direction.source_layer()) + || !row_present(&fact.current, direction.target_layer()) + { + continue; + } + let was_paired = old.is_some_and(|b| { + b.facets.iter().any(|previous| { + row_present(previous, direction.source_layer()) + && target_row( + project, + target, + &previous.selector(), + direction.target_layer(), + ) + .as_ref() + == Some(&(path.clone(), key.clone())) + }) + }); + if was_paired { + continue; + } + let row = scalar::value(&fact.current, direction.target_layer()) + .ok_or("Target row is inapplicable.")?; + let items = value + .pointer_mut(&path) + .and_then(Value::as_array_mut) + .ok_or("Target collection is unavailable.")?; + match &fact.selector { + FacetSelector::Member { .. } | FacetSelector::Literal(_) => { + if row == Value::Bool(true) && !items.contains(&key) { + items.push(key); + } else if row == Value::Bool(false) { + items.retain(|v| v != &key); + } + } + FacetSelector::Annotation(_) => { + items.retain(|v| { + serde_json::from_value::(v.clone()) + .ok() + .map(|v| serde_json::to_value(AnnotationKey::from(&v)).unwrap()) + .as_ref() + != Some(&key) + }); + if !row.is_null() { + items.push(row); + } + } + FacetSelector::Binding(_) => { + items.retain(|v| v.get("referenced") != Some(&key)); + if !row.is_null() { + items.push(row); + } + } + _ => unreachable!(), + } + } + Ok(value) +} + +fn row_present(fact: &FacetReview, layer: Layer) -> bool { + match fact { + FacetReview::Member { .. } | FacetReview::Literal { .. } => { + scalar::value(fact, layer) == Some(Value::Bool(true)) + } + FacetReview::Annotation { .. } | FacetReview::Binding { .. } => { + scalar::value(fact, layer).is_some_and(|value| !value.is_null()) + } + _ => false, + } +} + +fn row_key(facet: &FacetSelector, layer: Layer) -> Option { + match facet { + FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => { + chosen(keys, layer).map(|v| serde_json::json!(v)) + } + FacetSelector::Literal(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)), + FacetSelector::Annotation(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)), + _ => None, + } +} +fn target_row( + project: &Project, + target: Id, + facet: &FacetSelector, + layer: Layer, +) -> Option<(String, Value)> { + let key = row_key(facet, layer)?; + let (group, suffix) = match facet { + FacetSelector::Literal(_) => ( + FacetSelector::Data(DataFacet::Constructor), + "/Enumeration/literals", + ), + FacetSelector::Binding(_) => ( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + "/ScenarioUse/bindings", + ), + FacetSelector::Annotation(_) => ( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + "/Exchange/values", + ), + FacetSelector::Member { collection, .. } => match collection { + ReferenceCollection::FunctionInputItems => ( + FacetSelector::Port(PortFacet::Constructor), + "/FunctionInput/incoming_items", + ), + ReferenceCollection::FunctionOutputItems => ( + FacetSelector::Port(PortFacet::Constructor), + "/FunctionOutput/outgoing_items", + ), + ReferenceCollection::SignalItems => ( + FacetSelector::Event(EventFacet::Constructor), + "/Signal/items", + ), + ReferenceCollection::Steps => ( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + "/Branch/occurrences", + ), + ReferenceCollection::FragmentBranches => ( + FacetSelector::Occurrence(OccurrenceFacet::Constructor), + "/Fragment/branches", + ), + _ => return None, + }, + _ => return None, + }; + Some(( + format!("{}{suffix}", scalar::binding(project, target, &group)?), + key, + )) +} + +/// A selected descendant may fill untouched group fields during first-equal +/// acknowledgment, but cannot silently replace an authored group field. +pub(super) fn authored_agrees(original: &Value, authored: &Value, final_value: &Value) -> bool { + if original == authored || authored == final_value { + return true; + } + match (original, authored, final_value) { + (Value::Object(a), Value::Object(b), Value::Object(c)) if a.keys().eq(b.keys()) => { + b.iter().all(|(key, v)| { + c.get(key) + .is_some_and(|final_value| authored_agrees(&a[key], v, final_value)) + }) + } + _ => false, + } +} diff --git a/crates/oa-desktop/src/semantic_review/message_kind.rs b/crates/oa-desktop/src/semantic_review/message_kind.rs index 4d11dedc..ab37b1a3 100644 --- a/crates/oa-desktop/src/semantic_review/message_kind.rs +++ b/crates/oa-desktop/src/semantic_review/message_kind.rs @@ -5,12 +5,14 @@ use super::*; pub(super) fn selector() -> FacetSelector { FacetSelector::Occurrence(OccurrenceFacet::MessageKind) } +#[cfg(test)] pub(super) fn timer_selector() -> FacetSelector { FacetSelector::Occurrence(OccurrenceFacet::TimerExpression) } pub(super) fn handles(facet: &FacetSelector) -> bool { *facet == selector() } +#[cfg(test)] pub(super) fn value(project: &Project, id: Id) -> Option<&MessageKind> { match project.elements.get(&id)?.shared()? { SharedBody::Occurrence { @@ -20,13 +22,3 @@ pub(super) fn value(project: &Project, id: Id) -> Option<&MessageKind> { _ => None, } } -pub(super) fn available(project: &Project, trace: &Trace) -> bool { - value(project, trace.oa).is_some() && value(project, trace.sa).is_some() -} -/// MessageConstructor observations contain only the discriminator. Taking a -/// replacing group needs the complete typed payload, including Timer.expression. -pub(super) fn source_value(project: &Project, id: Id) -> Result { - let value = - value(project, id).ok_or("Message kind requires an existing Exchange occurrence.")?; - serde_json::to_value(value).map_err(|e| e.to_string()) -} diff --git a/crates/oa-desktop/src/semantic_review/message_kind_tests.rs b/crates/oa-desktop/src/semantic_review/message_kind_tests.rs index 82f6b8bc..dffc4261 100644 --- a/crates/oa-desktop/src/semantic_review/message_kind_tests.rs +++ b/crates/oa-desktop/src/semantic_review/message_kind_tests.rs @@ -206,10 +206,18 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex &mut app, Event::Text(expression.clone(), text_editor::Action::SelectAll), ); - let cursor = state(&app).message_draft.as_ref().unwrap().inputs[&expression] + let cursor = state(&app) + .group_drafts + .get(&message_kind::selector()) + .unwrap() + .inputs[&expression] .editor .cursor(); - let selection = state(&app).message_draft.as_ref().unwrap().inputs[&expression] + let selection = state(&app) + .group_drafts + .get(&message_kind::selector()) + .unwrap() + .inputs[&expression] .editor .selection(); send( @@ -221,17 +229,30 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex Event::Variant(kind, json!({"Timer":{"expression":""}})), ); assert_eq!( - state(&app).message_draft.as_ref().unwrap().inputs[&expression].raw, + state(&app) + .group_drafts + .get(&message_kind::selector()) + .unwrap() + .inputs[&expression] + .raw, " group β timer\n\t " ); assert_eq!( - state(&app).message_draft.as_ref().unwrap().inputs[&expression] + state(&app) + .group_drafts + .get(&message_kind::selector()) + .unwrap() + .inputs[&expression] .editor .cursor(), cursor ); assert_eq!( - state(&app).message_draft.as_ref().unwrap().inputs[&expression] + state(&app) + .group_drafts + .get(&message_kind::selector()) + .unwrap() + .inputs[&expression] .editor .selection(), selection @@ -621,7 +642,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t .error .as_ref() .unwrap() - .contains("different timer values") + .contains("different final values") ); send( &mut app, @@ -682,7 +703,12 @@ fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_l assert!(state(&app).error.is_some()); assert_eq!(state(&app).decisions.len(), 1); assert_eq!( - state(&app).message_draft.as_ref().unwrap().inputs[&expression].raw, + state(&app) + .group_drafts + .get(&message_kind::selector()) + .unwrap() + .inputs[&expression] + .raw, raw ); assert_eq!(app.document.bundle(), &before.bundle); diff --git a/crates/oa-desktop/src/semantic_review/tests.rs b/crates/oa-desktop/src/semantic_review/tests.rs index 9b775c70..25e3544d 100644 --- a/crates/oa-desktop/src/semantic_review/tests.rs +++ b/crates/oa-desktop/src/semantic_review/tests.rs @@ -568,7 +568,10 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() { send( &mut app, Event::Decide( - facet(DataFacet::Constructor), + FacetSelector::Literal(KeyPair { + oa: Some("Unpaired".into()), + sa: None, + }), Some(SemanticDecision::TakeSource), ), ); diff --git a/crates/oa-desktop/src/semantic_review/view.rs b/crates/oa-desktop/src/semantic_review/view.rs index f55ffad8..4be8c507 100644 --- a/crates/oa-desktop/src/semantic_review/view.rs +++ b/crates/oa-desktop/src/semantic_review/view.rs @@ -266,7 +266,7 @@ impl State { .iter() .filter(|f| f.drift == Drift::Unreviewed) .count(); - content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Other constructor groups and structured collections require further review.", self.decisions.len())).size(10).color(style::MUTED)); + content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Independent keyed/ordered decisions and historical reset remain pending.", self.decisions.len())).size(10).color(style::MUTED)); if let Some(trace) = trace { content = content.push(text(summary(project, trace)).size(10).color(style::MUTED)); } @@ -364,31 +364,42 @@ impl State { .and_then(|t| t.baseline.as_ref()) .and_then(|b| b.semantic.as_ref()) .and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector)); - let is_message_kind = message_kind::handles(&fact.selector); - let timer_previous = trace - .and_then(|t| t.baseline.as_ref()) - .and_then(|b| b.semantic.as_ref()) - .and_then(|b| { - b.facets - .iter() - .find(|f| f.selector() == message_kind::timer_selector()) - }); - let timer_current = self - .facts - .iter() - .find(|f| f.selector == message_kind::timer_selector()); + let is_group = scalar::grouped(&fact.selector); + let group_observations = |project: &Project, facts: Vec<&FacetReview>, layer| { + facts + .into_iter() + .filter(|f| constructors::contains(&fact.selector, &f.selector())) + .map(|f| { + format!( + "{}: {}", + title(project, &f.selector()), + observed(project, Some(f), layer) + ) + }) + .collect::>() + .join("\n") + }; let side = |layer, label: &str| { let mut before = observed(project, previous, layer); let mut current = observed(project, Some(&fact.current), layer); - if is_message_kind { - before.push_str(&format!( - "\nTimer expression: {}", - observed(project, timer_previous, layer) - )); - current.push_str(&format!( - "\nTimer expression: {}", - observed(project, timer_current.map(|f| &f.current), layer) - )); + if is_group { + before = group_observations( + project, + trace + .and_then(|t| t.baseline.as_ref()) + .and_then(|b| b.semantic.as_ref()) + .map(|b| b.facets.iter().collect()) + .unwrap_or_default(), + layer, + ); + if before.is_empty() { + before = "No previous group observations".into(); + } + current = group_observations( + project, + self.facts.iter().map(|f| &f.current).collect(), + layer, + ); } column![ text(format!( @@ -426,11 +437,17 @@ impl State { .color(style::TEAL), ) .push( - text(if is_message_kind { - self.target(project) - .and_then(|id| message_kind::source_value(proposed, id).ok()) - .map(|value| formatted(proposed, &value, &BTreeSet::new())) - .unwrap_or_else(|| "Message kind is not applicable.".into()) + text(if is_group { + semantic_facets(proposed, &proposed.traces[&trace.id], &self.references) + .ok() + .map(|facts| { + group_observations( + proposed, + facts.iter().map(|f| &f.current).collect(), + self.direction.target_layer(), + ) + }) + .unwrap_or_else(|| "Group payload is unavailable.".into()) } else { observed(proposed, Some(&after), self.direction.target_layer()) }) @@ -444,10 +461,11 @@ impl State { ); } if self.editable(project, &fact.selector) { - if is_message_kind { - detail = detail.push(text("A replacing message-kind decision reviews its Timer Expression as a group. The exact acknowledgment and retirement appear below.").size(12)); + if is_group { + detail = detail.push(text(constructors::description(&fact.selector)).size(12)); + detail = detail.push(text("Replacing a kind reviews its complete payload. Review every removed reference, row and prior observation in the exact scope below; changing kind retains your draft for switching back.").size(11)); if self.can_acknowledge_constructor(project, &fact.selector) { - detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged final kind also needs a separate Timer Expression decision for expression edits.").size(11).color(style::MUTED)); + detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged Candidate kind needs independent descendant decisions for payload edits.").size(11).color(style::MUTED)); } } let destination = if self.direction.target_layer() == Layer::Sa { @@ -472,8 +490,8 @@ impl State { ] .spacing(5); buttons = buttons.push(action( - if is_message_kind { - "Set final message kind…" + if is_group { + "Set final kind and payload…" } else { "Set final value…" }, @@ -485,52 +503,85 @@ impl State { )); if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) && let Some(draft) = self.candidate_draft(&fact.selector) - && let Some(path) = self + && let Some(paths) = self .target(project) - .and_then(|id| scalar::binding(project, id, &fact.selector)) + .and_then(|id| constructors::roots(project, id, &fact.selector)) { - detail = detail.push(text("Final destination value").size(12)).push( - crate::forms::scoped(draft, project, &path, &title(project, &fact.selector)) + detail = detail.push(text("Final destination value").size(12)); + for path in paths { + let label = if path.ends_with("/default") { + "Default" + } else if path.ends_with("/legacy") { + "Generic association" + } else { + "Kind and payload" + }; + detail = detail.push( + crate::forms::scoped( + draft, + project, + &path, + if is_group { label } else { "Final value" }, + ) .map(form_event), - ); + ); + } } } else if self.can_acknowledge_constructor(project, &fact.selector) { detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12)) .push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget)))) .push(action("Leave unselected", Event::Decide(fact.selector.clone(), None))); } else { - detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED)); + detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED)); } - if is_message_kind || fact.selector == message_kind::timer_selector() { - if self.decisions.contains_key(&message_kind::selector()) - && self.decisions.contains_key(&message_kind::timer_selector()) - { - detail = detail.push(text("Both kind and timer decisions are selected. An initial equal-kind acknowledgment can coexist with a timer decision; a replacing group requires one combined decision. Resolve any overlap explicitly; both drafts are retained.").size(11)) - .push(action("Replace timer decision with message-kind group", Event::Decide(message_kind::timer_selector(), None))) - .push(action("Leave message kind unselected; use timer decision", Event::Decide(message_kind::selector(), None))); + let overlaps: Vec<_> = self + .decisions + .keys() + .filter(|other| { + **other != fact.selector + && (constructors::contains(&fact.selector, other) + || constructors::contains(other, &fact.selector)) + }) + .collect(); + if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() { + detail = detail.push(text("Related decisions are selected. A first equal-kind acknowledgment can coexist with descendants; a replacing group requires one combined decision. Resolve overlaps explicitly; all drafts are retained.").size(11)); + for other in overlaps { + detail = detail.push(action( + format!("Leave {} unselected", title(project, other)), + Event::Decide(other.clone(), None), + )); } - if let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first()) { - detail = detail.push(text("Exact review scope from preview").size(13)); - for (label, selectors) in [ - ("Acknowledge", &review.acknowledged), - ("Retire previous observation", &review.retired), - ] { - detail = detail.push( - text(format!( - "{label}: {}", - if selectors.is_empty() { - "None".into() - } else { - selectors - .iter() - .map(|s| title(project, s)) - .collect::>() - .join(", ") - } - )) - .size(11), - ); - } + detail = detail.push(action( + "Leave this decision unselected; use the related decisions", + Event::Decide(fact.selector.clone(), None), + )); + } + if (is_group + || constructors::selectors().iter().any(|group| { + self.decisions.contains_key(group) && constructors::contains(group, &fact.selector) + })) + && let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first()) + { + detail = detail.push(text("Exact review scope from preview").size(13)); + for (label, selectors) in [ + ("Acknowledge", &review.acknowledged), + ("Retire previous observation", &review.retired), + ] { + detail = detail.push( + text(format!( + "{label}: {}", + if selectors.is_empty() { + "None".into() + } else { + selectors + .iter() + .map(|s| title(project, s)) + .collect::>() + .join(", ") + } + )) + .size(11), + ); } } if matches!( diff --git a/harness/release-semantic-gui.md b/harness/release-semantic-gui.md index 7f7ff52e..b839cf4c 100644 --- a/harness/release-semantic-gui.md +++ b/harness/release-semantic-gui.md @@ -1,8 +1,8 @@ -# Semantic desktop review — scalars and nested message kind +# Semantic desktop review — scalars and closed constructor groups -Status: milestones 1–2 implemented; the bounded MessageKind/TimerExpression -constructor slice is implemented. Full milestone 3, GUI parity, native acceptance -and release acceptance remain outstanding. +Status: milestones 1–2 and the closed constructor group forms are implemented. +Independent keyed/ordered review, historical pairing reset, multi-source review, +full GUI parity, native acceptance and release acceptance remain outstanding. Base: `d130013`. Dedicated build target: `D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned. @@ -17,10 +17,10 @@ Base: `d130013`. Dedicated build target: - Support every applicable scalar leaf through Keep, Take and typed final-value forms. Candidate records contain the complete combined selected result. Identity, alias and layer are protected. The closed core remains the validation boundary. -- State.Kind is a single structural scalar. Occurrence.MessageKind now supports - its complete nested group, including TimerExpression. Other constructor switches, keyed - membership/order/annotation/binding/attachment decisions and pairing resets belong - to milestone 3; show them read-only with an honest explanation. +- State.Kind is a single structural scalar. Data, Port, Occurrence, nested + MessageKind and Event support their closed constructor groups and complete + typed payloads. Independent keyed membership/order/annotation/binding/attachment + decisions and pairing resets remain visibly read-only outside a replacing group. - Core `6bb2583` permits first equal constructor acknowledgment without descendant expansion. The GUI exposes **Acknowledge equal kind only** when applicable; previously reviewed constructors remain governed by the core's switch rules. @@ -38,9 +38,9 @@ Base: `d130013`. Dedicated build target: 2. Scalar decisions and typed candidates; immutable initial observations; combined final records; exact impact acceptance; atomic document/source commit. Focused App event/source/history tests, strict Clippy and a dedicated desktop build. -3. Nested MessageKind/TimerExpression closure is implemented. Other constructor - groups, keyed and ordered facts, reset, complete artifact lifecycle, multi-source - review and remaining source/REST parity cases are outstanding. +3. All closed constructor forms are implemented, including nested MessageKind. + Independent keyed and ordered facts, reset, complete artifact lifecycle, + multi-source review and remaining source/REST parity cases are outstanding. 4. Root-owned native review, cross-surface fixtures, package/release acceptance. ## Verification @@ -94,7 +94,8 @@ button availability, exact source retention before Apply, and one-step undo. Occurrence.MessageKind is a complete closed nested family: Asynchronous, Call, Reply, Create, Delete and Timer. It is available only when both traced counterparts are Exchange occurrences. The only constructor-specific payload is the timer -expression. Outer occurrence replacement remains read-only. +expression. This first slice left outer occurrence replacement read-only; the later closed +constructor slice below now covers it. - Keep/Take/Candidate use the core's switch and first-equal acknowledgment policy. Taking a replacing group copies the typed MessageKind, including its expression. @@ -132,9 +133,8 @@ The build target is idle. No native application was launched by this task. ## Remaining work -Milestone 3 must add the remaining constructor closure/replacement, keyed and ordered -decisions, historical pairing reset, complete artifact lifecycle and multi-source -review. These facts are currently inspectable, with unsupported writes visibly +Milestone 3 must add independent keyed and ordered decisions, historical pairing +reset, complete artifact lifecycle and multi-source review. These facts are currently inspectable, with unsupported writes visibly identified. The reference table supplies explicit mappings for scalar reference Take; it does not claim to complete a reset or establish keyed acknowledgments. @@ -142,3 +142,86 @@ Fresh independent requirements/correctness review and root-owned native checks o the message-kind slice are required before release acceptance. Verify the compact kind picker, timer text/focus retention, overlap choices, exact closure/retirement, mapped Reply, blocked Timer contract, source reopening and toolbar wrapping. + + +## Remaining closed constructor families (this slice) + +Owned files: `semantic_review/**`, `semantic_review.rs`, the scoped Draft adapter +in `forms.rs`, semantic-only port choices in `forms/port_fields.rs`, and this ledger. +No shared traceability header, core, API, native state or source language edits. +Base: `f73551f`; same exclusive desktop target. + +| Constructor | Complete group payload | Independent fields | +| --- | --- | --- | +| Data | Definition, bounds, literal members, record supertype, default | Unit, owner and headers | +| Port | Contract, function item members, retained legacy direction/interface | Owner and headers | +| Occurrence | Body, nested message kind/timer, references, qualified annotations, bindings, branch and step order | Pre/postconditions, owner and headers | +| Event | Body, signal item members, time kind/expression or change expression | Owner and headers | +| MessageKind | Complete nested kind and timer expression | Other occurrence body properties | + +Each group owns an independent draft, including the nested MessageKind group. +Keep/Take/leave-unselected retain all drafts. Constructor picker changes save the +old branch value plus its raw inputs and editor state. Switching back restores them. +Port input/output changes also carry authored item reference strings, including +invalid strings, rather than falling back to stored UUIDs. A Data definition switch +leaves its existing default visible until the user deliberately changes or clears +it; Port's retained generic association has explicit add/edit/clear controls. + +The same combined candidate projection supplies dependent form context. Only the +selected roots are materialized; unrelated invalid scalar/group drafts stay local. +Full Take payloads use the public core typed reference visitor, so UUID-shaped prose +is never remapped. For historical same-kind Take, the closed adapter retains only +current target-present/source-absent rows without a previous source-present +observation on that target slot; the core independently +checks the supplied final record. No GUI acknowledgment, retirement or impact list +is synthesized. The core remains authoritative for those lists and applicability. + +Group details show every baseline/current/proposed group observation, including +qualified and historical row identities. Actual preview acknowledged/retired lists +remain explicit. A replacing group and selected descendants retain both decisions +and provide explicit leave-unselected actions. First-equal discriminator observation +can coexist with independent descendant decisions. Conflicting authored values +reject instead of silently discarding either draft's edits. + +Array controls support the whole authorized payload, including order, existing +owned branches/steps, signal items, annotations and scenario bindings. They do not +create referenced semantic objects, change their ownership, delete dependencies, +or infer reference correspondences. Incompatible payload/owner combinations stay +visible as validation failures. Apply still sends exactly one prepared Document +batch, preserving comments, source/model/history agreement and one-step undo/redo. + +New coverage exercises all six Data definitions and companion defaults in both +directions; all eight Occurrence and all three Event kinds; all four Port forms; +combined owner context; nested and scalar draft retention; explicit overlap; +protected fields; mapped record, state, event item and scenario binding payloads; +complete owned branch/step order; historical retirement and first-equal scope; +90 Data/Event kind-pair and direction comparisons against the core-only preview; +source comments/reopen and atomic undo/redo. Full checks and core dependency status +will be recorded at freeze. This task launches no native application. + +The delegated keyed-closure fix `43370de` is integrated into SEM as `9c3184b`. +Reverse Literal/Member/Binding/Annotation closure and historical local-member +preservation now pass the desktop desired-success tests. The separately delegated existing-target Port fix `8a75294` is integrated into +SEM as `7512ac8`. Fresh reverse Keep and Candidate reviews can preserve/edit the +OA Legacy association; taking a typed contract into OA still rejects. Both fixes +are covered by desired-success desktop tests; no GUI workaround weakens validation. + +Native acceptance remains root-owned: use the existing trace Review semantics +entry, inspect complete group before/after and retired identities, switch variants +away/back, resolve explicit nested overlaps, repair invalid defaults/references, +apply once, inspect source, undo/redo, save and reopen at 1100×700 and 1440×900. + + +## Closed constructor freeze verification + +On integrated SEM base `7512ac8`, all **299 desktop tests pass**. This adds 13 +constructor workflow tests and one scoped port form regression to the previous +285-test slice. The new suite includes 90 complete Data/Event kind-pair/direction +comparisons against core previews, all closed candidate kinds, mapped qualified +annotation addresses, bindings and references, actual reverse Legacy edits, owned +branch/step ordering, source/model/history agreement, retained drafts and strict +scope protection. Both delegated core defects are resolved and exercised. + +Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and the +desktop build all pass using the dedicated target and commands above. Native checks remain root-owned. No native application +was launched, and full milestone 3 / GUI parity / release acceptance remain open.