From 036276813cf9d1a2257d2357ec023e381bca10ae Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 06:07:39 +0300 Subject: [PATCH] Add typed scalar semantic review to desktop workflow --- crates/oa-desktop/src/app.rs | 46 + crates/oa-desktop/src/forms.rs | 30 + crates/oa-desktop/src/main.rs | 1 + crates/oa-desktop/src/model_menu.rs | 7 +- crates/oa-desktop/src/semantic_review.rs | 547 ++++++++++++ .../oa-desktop/src/semantic_review/scalar.rs | 288 +++++++ .../oa-desktop/src/semantic_review/tests.rs | 784 ++++++++++++++++++ crates/oa-desktop/src/semantic_review/view.rs | 580 +++++++++++++ crates/oa-desktop/src/tests.rs | 5 +- crates/oa-desktop/src/views.rs | 45 +- crates/oa-desktop/src/workflow.rs | 14 +- harness/release-semantic-gui.md | 82 ++ 12 files changed, 2417 insertions(+), 12 deletions(-) create mode 100644 crates/oa-desktop/src/semantic_review.rs create mode 100644 crates/oa-desktop/src/semantic_review/scalar.rs create mode 100644 crates/oa-desktop/src/semantic_review/tests.rs create mode 100644 crates/oa-desktop/src/semantic_review/view.rs create mode 100644 harness/release-semantic-gui.md diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index 8d395af1..b4ff6561 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -106,6 +106,7 @@ pub enum Message { SelectProject, Search(String), StartTransition, + StartSemanticReview(Id), Workflow(crate::workflow::Event), CollapseExplorer(Id), ExplorerGroup(crate::explorer::Group), @@ -1315,12 +1316,57 @@ impl App { } } } + Message::StartSemanticReview(selected) => { + if self.apply() { + match crate::semantic_review::State::new(self.project(), selected) { + Ok(state) => { + self.workflow = + Some(crate::workflow::Workflow::Semantic(Box::new(state))) + } + Err(error) => self.status = error, + } + } + } Message::Workflow(event) => { if matches!(event, crate::workflow::Event::Cancel) { self.workflow = None; return Task::none(); } if let Some(mut workflow) = self.workflow.take() { + if let crate::workflow::Workflow::Semantic(state) = &mut workflow { + match event { + crate::workflow::Event::Semantic(event) => { + state.update(self.project(), event) + } + crate::workflow::Event::Apply => { + let focus = state.target(self.project()); + match state.command(self.project()) { + Ok(command) => { + if self.execute(command) { + if let Some(target) = focus { + self.selected = target; + if let Some(layer) = self.project().layer_of(target) + { + self.layer = layer; + } + self.refresh_draft(); + } + self.status = "Semantic review applied · model, source and baseline form one undoable change".into(); + return Task::none(); + } + state.error = Some(self.status.clone()); + } + Err(error) => { + self.status = error.clone(); + state.error = Some(error); + } + } + } + _ => {} + } + self.workflow = Some(workflow); + return Task::none(); + } let stay_in_catalog = self.page == Page::Catalog && matches!(&workflow, crate::workflow::Workflow::Ports(_)); let transition_focus = match &workflow { diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index a7f30ba1..f7be46ee 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -130,8 +130,22 @@ impl Draft { } } pub fn materialize(&self) -> Result { + self.materialize_inputs(|_| true) + } + /// Materialize selected scalar subtrees without parsing retained, unselected drafts. + pub fn materialize_scopes(&self, scopes: &[String]) -> Result { + self.materialize_inputs(|path| { + scopes + .iter() + .any(|root| path == root || path.starts_with(&format!("{root}/"))) + }) + } + fn materialize_inputs(&self, include: impl Fn(&str) -> bool) -> Result { let mut value = self.value.clone(); for (path, input) in &self.inputs { + if !include(path) { + continue; + } let Some(target) = value.pointer_mut(path) else { continue; }; @@ -1370,6 +1384,22 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> { content.into() } +/// Render one closed property subtree for a workflow-owned typed draft. +/// The caller routes messages to that draft and authorizes the selected scope. +pub fn scoped<'a>( + draft: &'a Draft, + project: &'a Project, + path: &str, + label: &str, +) -> View<'a, Message> { + match draft.value.pointer(path) { + Some(value) => property(draft, project, value, path, label, false), + None => text("This property is not applicable to the current target.") + .size(12) + .into(), + } +} + fn property<'a>( draft: &'a Draft, project: &'a Project, diff --git a/crates/oa-desktop/src/main.rs b/crates/oa-desktop/src/main.rs index c8f5572a..8926c627 100644 --- a/crates/oa-desktop/src/main.rs +++ b/crates/oa-desktop/src/main.rs @@ -19,6 +19,7 @@ mod port_edit; mod port_preview; mod port_workflow; mod route_edit; +mod semantic_review; mod source_navigation; mod source_tree; mod source_workspace; diff --git a/crates/oa-desktop/src/model_menu.rs b/crates/oa-desktop/src/model_menu.rs index 87ccd5f0..931e9324 100644 --- a/crates/oa-desktop/src/model_menu.rs +++ b/crates/oa-desktop/src/model_menu.rs @@ -415,10 +415,15 @@ pub fn popup(app: &App) -> Option> { if p.layer_of(id).is_some() { entries.push(item("Create relationship…", Message::AddRelation, true)); entries.push(item( - "Create / update counterpart…", + "Create counterpart / review prose…", Message::StartTransition, true, )); + entries.push(item( + "Review semantics…", + Message::StartSemanticReview(id), + p.traces.values().any(|t| t.oa == id || t.sa == id), + )); } entries.push(item( "Attach artifact reference…", diff --git a/crates/oa-desktop/src/semantic_review.rs b/crates/oa-desktop/src/semantic_review.rs new file mode 100644 index 00000000..b0335746 --- /dev/null +++ b/crates/oa-desktop/src/semantic_review.rs @@ -0,0 +1,547 @@ +//! Deliberate semantic review of one existing trace; drafts never mutate the model. +use crate::{ + app::{Choice, Message}, + forms::Draft, +}; +use iced::widget::text_editor; +use oa_core::{transitions::*, *}; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; + +mod scalar; +mod view; +pub(crate) use view::summary; +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Tab { + Facts, + References, + Impacts, +} + +#[derive(Debug, Clone)] +pub enum Event { + Trace(Choice), + Direction(TransitionDirection), + Select(FacetSelector), + Decide(FacetSelector, Option), + Input(String, String), + Text(String, text_editor::Action), + Shape(String, Value), + Variant(String, Value), + Array(String, Option, i32), + Append(String, Value), + ValuePath(String, crate::data_values::FieldPathKey), + Tab(Tab), + ShowUnchanged(bool), + ReferenceOa(Choice), + ReferenceSa(Choice), + ReferenceSearch(String), + AddReference, + RemoveReference(Id), + AcceptImpact(usize, bool), + Preview, +} + +pub struct State { + pub anchor: Id, + pub trace: Option, + pub direction: TransitionDirection, + pub facts: Vec, + pub selected: Option, + pub decisions: BTreeMap, + pub references: BTreeMap, + pub draft: Option, + pub plan: Option, + pub proposed: Option, + pub error: Option, + pub tab: Tab, + pub show_unchanged: bool, + pub reference_oa: Option, + pub reference_sa: Option, + pub reference_search: String, + accepted_impacts: Vec, +} + +impl State { + pub fn new(project: &Project, selected: Id) -> Result { + let explicit = project.traces.get(&selected); + let anchor = explicit.map_or(selected, |trace| trace.oa); + let layer = project + .layer_of(anchor) + .ok_or("Select an OA or SA object, relationship or trace to review.")?; + let choices: Vec<_> = project + .traces + .values() + .filter(|t| t.oa == anchor || t.sa == anchor) + .collect(); + if choices.is_empty() { + return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into()); + } + let trace = explicit + .map(|t| t.id) + .or_else(|| (choices.len() == 1).then(|| choices[0].id)); + let mut state = Self { + anchor, + trace, + direction: if layer == Layer::Oa { + TransitionDirection::OaToSa + } else { + TransitionDirection::SaToOa + }, + facts: vec![], + selected: None, + decisions: BTreeMap::new(), + references: BTreeMap::new(), + draft: None, + plan: None, + proposed: None, + error: None, + tab: Tab::Facts, + show_unchanged: false, + reference_oa: None, + reference_sa: None, + reference_search: String::new(), + accepted_impacts: vec![], + }; + state.load_trace(project); + Ok(state) + } + + pub fn target(&self, project: &Project) -> Option { + let trace = project.traces.get(&self.trace?)?; + Some(if self.direction == TransitionDirection::OaToSa { + trace.sa + } else { + trace.oa + }) + } + + fn load_trace(&mut self, project: &Project) { + self.facts.clear(); + self.decisions.clear(); + self.selected = None; + self.accepted_impacts.clear(); + self.references.clear(); + self.draft = self + .target(project) + .and_then(|id| Draft::from_project(project, id)); + if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) { + self.references.insert(trace.oa, trace.sa); + } + self.refresh(project); + } + + pub fn update(&mut self, project: &Project, event: Event) { + let mut replan = true; + let result = match event { + Event::Trace(choice) => { + if !project + .traces + .get(&choice.id) + .is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor) + { + Err("Choose one of this object's existing traces.".into()) + } else { + self.trace = Some(choice.id); + self.load_trace(project); + return; + } + } + Event::Direction(direction) => { + self.direction = direction; + self.load_trace(project); + return; + } + Event::Select(facet) => { + self.selected = Some(facet); + replan = false; + Ok(()) + } + Event::Decide(facet, decision) => { + if let Some(decision) = decision { + if !self.editable(project, &facet) + && !(decision == SemanticDecision::KeepTarget + && self.can_acknowledge_constructor(project, &facet)) + { + Err("This fact needs the structured review workflow; its history remains unchanged.".into()) + } else { + self.decisions.insert(facet, decision); + Ok(()) + } + } else { + self.decisions.remove(&facet); + Ok(()) + } + } + Event::Input(path, value) => self.edit(project, &path, |d| { + d.input(&path, value); + Ok(()) + }), + Event::Text(path, action) => self.edit(project, &path, |d| { + d.edit_text(&path, action); + Ok(()) + }), + Event::Shape(path, value) => self.edit(project, &path, |d| d.shape(&path, value)), + Event::Variant(path, value) => self.edit(project, &path, |d| { + if d.materialize()? + .pointer(&path) + .is_some_and(|v| crate::forms::same_variant(v, &value)) + { + Ok(()) + } else { + d.shape(&path, value) + } + }), + Event::Array(path, index, direction) => { + self.edit(project, &path, |d| d.array(&path, index, direction)) + } + Event::Append(path, value) => self.edit(project, &path, |d| d.append(&path, value)), + Event::ValuePath(path, key) => self.edit(project, &path, |d| d.value_path(&path, key)), + Event::Tab(tab) => { + self.tab = tab; + replan = false; + Ok(()) + } + Event::ShowUnchanged(show) => { + self.show_unchanged = show; + replan = false; + Ok(()) + } + Event::ReferenceOa(choice) => { + self.reference_oa = Some(choice); + replan = false; + Ok(()) + } + Event::ReferenceSa(choice) => { + self.reference_sa = Some(choice); + replan = false; + Ok(()) + } + Event::ReferenceSearch(value) => { + self.reference_search = value; + replan = false; + Ok(()) + } + Event::AddReference => match (&self.reference_oa, &self.reference_sa) { + (Some(oa), Some(sa)) => { + self.references.insert(oa.id, sa.id); + Ok(()) + } + _ => Err("Choose both an Operational and a System reference.".into()), + }, + Event::RemoveReference(id) => { + self.references.remove(&id); + Ok(()) + } + Event::AcceptImpact(index, accepted) => { + if let Some(impact) = self + .plan + .as_ref() + .and_then(|p| p.impacts.get(index)) + .cloned() + { + self.accepted_impacts.retain(|i| i.object != impact.object); + if accepted { + self.accepted_impacts.push(impact); + } + } + self.refresh(project); + return; + } + Event::Preview => Ok(()), + }; + if let Err(error) = result { + self.error = Some(error); + self.plan = None; + self.proposed = None; + return; + } + if replan { + self.accepted_impacts.clear(); + self.refresh(project); + } + } + + fn edit( + &mut self, + project: &Project, + path: &str, + action: impl FnOnce(&mut Draft) -> Result<(), String>, + ) -> Result<(), String> { + let facet = self.selected.as_ref().ok_or("Choose a property first.")?; + if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) { + return Err("Choose Set final value before editing this property.".into()); + } + let root = 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}/")) { + return Err("Only the selected scalar property can change in this draft.".into()); + } + let draft = self.draft.as_mut().ok_or("Choose an existing target.")?; + // The scoped view sends only descendants of the selected scalar property. + // 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()); + } + action(draft) + } + + pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool { + self.target(project) + .and_then(|id| scalar::binding(project, id, facet)) + .is_some() + && !scalar::grouped(facet) + } + + pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool { + if !scalar::grouped(facet) { + return false; + } + let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else { + return false; + }; + if trace + .baseline + .as_ref() + .and_then(|b| b.semantic.as_ref()) + .is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet)) + { + return false; + } + self.facts + .iter() + .find(|f| f.selector == *facet) + .is_some_and(|f| { + scalar::value(&f.current, Layer::Oa).is_some() + && scalar::observation(&f.current, Layer::Oa) + == scalar::observation(&f.current, Layer::Sa) + }) + } + + fn request(&self, project: &Project) -> Result { + let trace = self + .trace + .and_then(|id| project.traces.get(&id)) + .ok_or("Choose the trace whose review history you want to update.")?; + let (source, target) = if self.direction == TransitionDirection::OaToSa { + (trace.oa, trace.sa) + } else { + (trace.sa, trace.oa) + }; + let mut transition = TransitionRequest::new(self.direction, vec![source]); + transition.mappings.push(TransitionMapping { + source, + target: Some(target), + target_kind: None, + }); + let mut request = SemanticTransitionRequest::new(transition); + request.traces.insert(source, trace.id); + request.references = self.references.clone(); + request.reviews.push(SemanticReviewRequest { + source, + resolutions: self + .decisions + .iter() + .map(|(facet, decision)| FacetResolution { + facet: facet.clone(), + decision: *decision, + }) + .collect(), + reset: vec![], + }); + request.accepted_impacts = self.accepted_impacts.clone(); + if self + .decisions + .values() + .any(|d| *d == SemanticDecision::Candidate) + { + let original = + Draft::from_project(project, target).ok_or("Target has no editable record.")?; + let mut final_value = original.value; + let scopes: Vec<_> = self + .decisions + .iter() + .filter(|(_, decision)| **decision == SemanticDecision::Candidate) + .filter_map(|(facet, _)| scalar::binding(project, target, facet)) + .collect(); + let draft = self + .draft + .as_ref() + .ok_or("Target draft is unavailable.")? + .materialize_scopes(&scopes)?; + let mut map = BTreeMap::new(); + for (oa, sa) in &self.references { + let (from, to) = if self.direction == TransitionDirection::OaToSa { + (*oa, *sa) + } else { + (*sa, *oa) + }; + if map.insert(from, to).is_some_and(|old| old != to) { + return Err("Reverse reference correspondence is ambiguous.".into()); + } + } + map.insert(source, target); + for (facet, decision) in &self.decisions { + if *decision == SemanticDecision::KeepTarget { + continue; + } + let path = scalar::binding(project, target, facet) + .ok_or("Selected property is not applicable to the target.")?; + let value = match decision { + SemanticDecision::KeepTarget => continue, + SemanticDecision::Candidate => draft + .pointer(&path) + .cloned() + .ok_or("Candidate property is no longer available.")?, + SemanticDecision::TakeSource => { + let fact = capture_facet(project, trace.oa, trace.sa, facet)?; + scalar::source_value(&fact, self.direction.source_layer(), &map)? + } + }; + *final_value + .pointer_mut(&path) + .ok_or("Final target property is unavailable.")? = value; + } + let candidate = if project.elements.contains_key(&target) { + SemanticRecord::Element( + serde_json::from_value(final_value) + .map_err(|e| format!("Final target value: {e}"))?, + ) + } else { + SemanticRecord::Relation( + serde_json::from_value(final_value) + .map_err(|e| format!("Final target value: {e}"))?, + ) + }; + request.candidates.push(candidate); + } + Ok(request) + } + + pub fn refresh(&mut self, project: &Project) { + self.plan = None; + self.proposed = None; + self.error = None; + let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else { + self.error = Some( + "Several traces are available. Choose the correspondence and review history above." + .into(), + ); + return; + }; + match semantic_facets(project, trace, &self.references) { + Ok(facts) => self.facts = facts, + Err(error) => { + self.error = Some(error); + return; + } + } + if self + .selected + .as_ref() + .is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s)) + { + self.selected = self + .facts + .iter() + .find(|f| f.drift != Drift::Unchanged) + .or(self.facts.first()) + .map(|f| f.selector.clone()); + } + let request = match self.request(project) { + Ok(request) => request, + Err(error) => { + self.error = Some(error); + return; + } + }; + match plan_semantic_transition(project, request) { + Ok(plan) => { + if plan.conflicts.is_empty() { + match prepare_semantic_transition(project, &plan) { + Ok(prepared) => self.proposed = Some(prepared.project), + Err(error) => self.error = Some(error.to_string()), + } + } + self.plan = Some(plan); + } + Err(error) => self.error = Some(error.to_string()), + } + } + + pub fn command(&mut self, project: &Project) -> Result { + if self.decisions.is_empty() { + return Err("Choose at least one property to review.".into()); + } + if let Some(error) = &self.error { + return Err(error.clone()); + } + let plan = self + .plan + .as_ref() + .ok_or("Refresh the review preview before applying.")?; + prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?; + Ok(Command::Batch(plan.commands.clone())) + } + + pub fn can_apply(&self) -> bool { + !self.decisions.is_empty() + && self.error.is_none() + && self + .plan + .as_ref() + .is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some()) + } + + pub fn all_references(&self, project: &Project, layer: Layer) -> Vec { + let historical: BTreeSet<_> = self + .facts + .iter() + .flat_map(|f| scalar::references(&f.current)) + .chain( + self.trace + .and_then(|id| project.traces.get(&id)) + .and_then(|t| t.baseline.as_ref()) + .and_then(|b| b.semantic.as_ref()) + .into_iter() + .flat_map(|b| b.facets.iter().flat_map(scalar::references)), + ) + .collect(); + let mut ids: BTreeSet<_> = project + .elements + .keys() + .chain(project.relations.keys()) + .chain(project.artifact_links.keys()) + .filter(|id| project.layer_of(**id) == Some(layer)) + .copied() + .collect(); + ids.extend(historical.into_iter().filter(|id| !project.contains(*id))); + let search = self.reference_search.to_lowercase(); + let mut choices: Vec<_> = ids + .into_iter() + .map(|id| view::choice(project, id)) + .filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search)) + .collect(); + choices.sort_by(|a, b| a.label.cmp(&b.label)); + choices + } +} + +pub fn form_event(message: Message) -> Message { + let event = match message { + Message::Field(path, value) => Event::Input(path, value), + Message::FieldText(path, action) => Event::Text(path, action), + Message::FieldShape(path, value) => Event::Shape(path, value), + Message::FieldVariant(path, value) => Event::Variant(path, value), + Message::FieldArray(path, index, direction) => Event::Array(path, index, direction), + Message::FieldAppend(path, value) => Event::Append(path, value), + Message::FieldValuePath(path, key) => Event::ValuePath(path, key), + _ => Event::Preview, + }; + Message::Workflow(crate::workflow::Event::Semantic(event)) +} diff --git a/crates/oa-desktop/src/semantic_review/scalar.rs b/crates/oa-desktop/src/semantic_review/scalar.rs new file mode 100644 index 00000000..321cf724 --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/scalar.rs @@ -0,0 +1,288 @@ +//! Fixed typed scalar bindings for property forms. Core validates every result. +use oa_core::*; +use serde_json::Value; +use std::collections::{BTreeMap, BTreeSet}; +struct Own { + whole: Value, + prefix: Vec, + kind: String, +} +fn enum_tag(value: &Value) -> Option<&str> { + value.as_str().or_else(|| { + value + .as_object() + .and_then(|v| v.keys().next().map(String::as_str)) + }) +} +impl Own { + fn get(project: &Project, id: Id) -> Option { + let (whole, element) = if let Some(e) = project.elements.get(&id) { + (serde_json::to_value(e).ok()?, true) + } else { + ( + serde_json::to_value(project.relations.get(&id)?).ok()?, + false, + ) + }; + let mut prefix = vec!["body".to_string()]; + let mut value = &whole["body"]; + if element { + let layer = enum_tag(value)?.to_string(); + prefix.push(layer.clone()); + value = &value[&layer]; + if enum_tag(value) == Some("Shared") { + prefix.push("Shared".into()); + value = &value["Shared"]; + } + } + let kind = enum_tag(value)?.to_string(); + prefix.push(kind.clone()); + Some(Self { + whole, + prefix, + kind, + }) + } +} +fn at<'a>(mut value: &'a Value, path: &[String]) -> Option<&'a Value> { + for part in path { + value = value.get(part)?; + } + Some(value) +} +fn path(own: &Own, facet: &FacetSelector) -> Option> { + use FacetSelector as F; + let (family, parts): (&str, &[&str]) = match facet { + F::Header(HeaderFacet::Name) => return Some(vec!["name".into()]), + F::Header(HeaderFacet::Description) => return Some(vec!["description".into()]), + F::Header(HeaderFacet::Parent) => { + return own.whole.get("parent").map(|_| vec!["parent".into()]); + } + F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]), + F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]), + F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]), + F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]), + F::Data(DataFacet::Constructor) => ("DataType", &["definition"]), + F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]), + F::Data(DataFacet::IntegerMaximum) => ("DataType", &["definition", "Integer", "maximum"]), + F::Data(DataFacet::RealMinimum) => ("DataType", &["definition", "Real", "minimum"]), + F::Data(DataFacet::RealMaximum) => ("DataType", &["definition", "Real", "maximum"]), + F::Data(DataFacet::StringMinimumLength) => { + ("DataType", &["definition", "String", "minimum_length"]) + } + F::Data(DataFacet::StringMaximumLength) => { + ("DataType", &["definition", "String", "maximum_length"]) + } + F::Data(DataFacet::RecordSupertype) => ("DataType", &["definition", "Record", "supertype"]), + F::Data(DataFacet::Default) => ("DataType", &["default"]), + F::Data(DataFacet::Unit) => ("DataType", &["unit"]), + F::Field(FieldFacet::Type) => ("DataField", &["type_ref"]), + F::Field(FieldFacet::Minimum) => ("DataField", &["minimum"]), + F::Field(FieldFacet::Maximum) => ("DataField", &["maximum"]), + F::Field(FieldFacet::Unit) => ("DataField", &["unit"]), + F::Field(FieldFacet::Direction) => ("DataField", &["direction"]), + F::Field(FieldFacet::Storage) => ("DataField", &["storage"]), + F::Field(FieldFacet::Default) => ("DataField", &["default"]), + F::Port(PortFacet::Constructor) => ("Port", &["contract"]), + F::Port(PortFacet::ComponentKind) => ("Port", &["contract", "Component", "kind"]), + F::Port(PortFacet::Orientation) => ("Port", &["contract", "Component", "orientation"]), + F::Port(PortFacet::LegacyDirection) => ("Port", &["legacy", "direction"]), + F::Port(PortFacet::LegacyInterface) => ("Port", &["legacy", "interface"]), + F::Scenario(ScenarioFacet::Viewpoint) => ("Scenario", &["viewpoint"]), + F::Scenario(ScenarioFacet::Context) => ("Scenario", &["context"]), + F::Scenario(ScenarioFacet::Precondition) => ("Scenario", &["precondition"]), + F::Scenario(ScenarioFacet::Postcondition) => ("Scenario", &["postcondition"]), + F::Lifeline(LifelineFacet::Represented) => ("Lifeline", &["represented"]), + F::Occurrence(OccurrenceFacet::Constructor) => ("Occurrence", &["body"]), + F::Occurrence(OccurrenceFacet::Precondition) => ("Occurrence", &["precondition"]), + F::Occurrence(OccurrenceFacet::Postcondition) => ("Occurrence", &["postcondition"]), + F::Occurrence(OccurrenceFacet::Exchange) => { + ("Occurrence", &["body", "Exchange", "exchange"]) + } + F::Occurrence(OccurrenceFacet::Sender) => ("Occurrence", &["body", "Exchange", "sender"]), + F::Occurrence(OccurrenceFacet::Receiver) => { + ("Occurrence", &["body", "Exchange", "receiver"]) + } + F::Occurrence(OccurrenceFacet::MessageKind) => { + ("Occurrence", &["body", "Exchange", "message_kind"]) + } + F::Occurrence(OccurrenceFacet::TimerExpression) => ( + "Occurrence", + &["body", "Exchange", "message_kind", "Timer", "expression"], + ), + F::Occurrence(OccurrenceFacet::ReplyTo) => { + ("Occurrence", &["body", "Exchange", "reply_to"]) + } + F::Occurrence(OccurrenceFacet::Action) => ("Occurrence", &["body", "Action", "action"]), + F::Occurrence(OccurrenceFacet::ActionLifeline) => { + ("Occurrence", &["body", "Action", "lifeline"]) + } + F::Occurrence(OccurrenceFacet::ControlKind) => ("Occurrence", &["body", "Control", "kind"]), + F::Occurrence(OccurrenceFacet::Sequence) => { + ("Occurrence", &["body", "SequenceReference", "sequence"]) + } + F::Occurrence(OccurrenceFacet::Scenario) => { + ("Occurrence", &["body", "ScenarioUse", "scenario"]) + } + F::Occurrence(OccurrenceFacet::State) => ("Occurrence", &["body", "State", "state"]), + F::Occurrence(OccurrenceFacet::StateLifeline) => { + ("Occurrence", &["body", "State", "lifeline"]) + } + F::Occurrence(OccurrenceFacet::FragmentOperator) => { + ("Occurrence", &["body", "Fragment", "operator"]) + } + F::Occurrence(OccurrenceFacet::BranchGuard) => ("Occurrence", &["body", "Branch", "guard"]), + F::Event(EventFacet::Constructor) => ("Event", &["body"]), + F::Event(EventFacet::TimeKind) => ("Event", &["body", "Time", "kind"]), + F::Event(EventFacet::TimeExpression) => ("Event", &["body", "Time", "expression"]), + F::Event(EventFacet::ChangeExpression) => ("Event", &["body", "Change", "expression"]), + F::State(StateFacet::Kind) => ("State", &["kind"]), + F::State(StateFacet::Entry) => ("State", &["entry"]), + F::State(StateFacet::Exit) => ("State", &["exit"]), + F::State(StateFacet::DoActivity) => ("State", &["do_activity"]), + F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]), + F::Relation(RelationFacet::Source) => ("*", &["source"]), + F::Relation(RelationFacet::Target) => ("*", &["target"]), + F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]), + F::Relation(RelationFacet::Transmission) => ("InterfaceItemAllocation", &["transmission"]), + F::Relation(RelationFacet::Acquisition) => ("InterfaceItemAllocation", &["acquisition"]), + F::Relation(RelationFacet::SourceRole) => ("DataAssociation", &["source_role"]), + F::Relation(RelationFacet::TargetRole) => ("DataAssociation", &["target_role"]), + F::Relation(RelationFacet::SourceMinimum) => ("DataAssociation", &["source_minimum"]), + F::Relation(RelationFacet::SourceMaximum) => ("DataAssociation", &["source_maximum"]), + F::Relation(RelationFacet::TargetMinimum) => ("DataAssociation", &["target_minimum"]), + F::Relation(RelationFacet::TargetMaximum) => ("DataAssociation", &["target_maximum"]), + F::Relation(RelationFacet::SourceNavigable) => ("DataAssociation", &["source_navigable"]), + F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]), + F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]), + F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]), + F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]), + F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]), + F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]), + F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]), + F::Relation(RelationFacet::Trigger) => ("StateTransition", &["trigger"]), + F::Relation(RelationFacet::Guard) => ("StateTransition", &["guard"]), + F::Relation(RelationFacet::Effect) => ("StateTransition", &["effect"]), + F::Relation(RelationFacet::StateActionPhase) => ("StateAction", &["phase"]), + _ => return None, + }; + if family != "*" && own.kind != family { + return None; + } + let mut path = own.prefix.clone(); + path.extend(parts.iter().map(|s| s.to_string())); + at(&own.whole, &path).map(|_| path) +} + +pub(super) fn binding(project: &Project, id: Id, facet: &FacetSelector) -> Option { + let own = Own::get(project, id)?; + path(&own, facet).map(|parts| format!("/{}", parts.join("/"))) +} +pub(super) fn grouped(facet: &FacetSelector) -> bool { + matches!( + facet, + FacetSelector::Data(DataFacet::Constructor) + | FacetSelector::Port(PortFacet::Constructor) + | FacetSelector::Occurrence( + OccurrenceFacet::Constructor | OccurrenceFacet::MessageKind + ) + | FacetSelector::Event(EventFacet::Constructor) + ) +} +fn pair_json(fact: &FacetReview) -> Value { + let json = serde_json::to_value(fact).expect("Typed semantic fact"); + let (family, value) = json + .as_object() + .expect("Typed family") + .iter() + .next() + .expect("Family"); + match family.as_str() { + "Member" | "Order" | "Literal" | "Annotation" | "Binding" | "Child" | "Incident" => { + value["review"].clone() + } + "Artifact" => value["review"] + .as_object() + .expect("Artifact leaf") + .values() + .next() + .expect("Artifact value") + .clone(), + _ => value + .as_object() + .expect("Scalar leaf") + .values() + .next() + .expect("Scalar value") + .clone(), + } +} +pub(super) fn observation(fact: &FacetReview, layer: Layer) -> Value { + pair_json(fact)[match layer { + Layer::Oa => "oa", + Layer::Sa => "sa", + }] + .clone() +} +pub(super) fn value(fact: &FacetReview, layer: Layer) -> Option { + observation(fact, layer).get("Value").cloned() +} +pub(super) fn source_value( + fact: &FacetReview, + source: Layer, + correspondence: &BTreeMap, +) -> Result { + // Retain only the source observation before invoking the core's typed UUID + // visitor. UUID-shaped prose must never be treated as a reference. + let mut json = serde_json::to_value(fact).map_err(|e| e.to_string())?; + let leaf = json + .as_object_mut() + .ok_or("Missing scalar family")? + .values_mut() + .next() + .ok_or("Missing scalar family")? + .as_object_mut() + .ok_or("Missing scalar leaf")? + .values_mut() + .next() + .ok_or("Missing scalar leaf")?; + let selected = observation(fact, source); + leaf["oa"] = selected.clone(); + leaf["sa"] = selected; + let typed = serde_json::from_value(json).map_err(|e| e.to_string())?; + let mut baseline = SemanticBaseline { + facets: vec![typed], + }; + let mut missing = BTreeSet::new(); + visit_semantic_references(&mut baseline, |id| { + if let Some(mapped) = correspondence.get(id) { + *id = *mapped; + } else { + missing.insert(*id); + } + }); + if !missing.is_empty() { + return Err(format!( + "Choose reference counterparts before taking this value: {}", + missing + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )); + } + value(&baseline.facets[0], source) + .ok_or("Source property is not applicable; review its constructor first.".into()) +} +pub(super) fn references(fact: &FacetReview) -> BTreeSet { + let mut ids = BTreeSet::new(); + visit_semantic_references( + &mut SemanticBaseline { + facets: vec![fact.clone()], + }, + |id| { + ids.insert(*id); + }, + ); + ids +} diff --git a/crates/oa-desktop/src/semantic_review/tests.rs b/crates/oa-desktop/src/semantic_review/tests.rs new file mode 100644 index 00000000..e864f89c --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/tests.rs @@ -0,0 +1,784 @@ +use super::*; +use crate::{app::App, document::Document, workflow::Workflow}; +use serde_json::json; + +fn send(app: &mut App, event: Event) { + let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event))); +} +fn apply(app: &mut App) { + let _ = app.update(Message::Workflow(crate::workflow::Event::Apply)); +} +fn state(app: &App) -> &State { + let Some(Workflow::Semantic(state)) = &app.workflow else { + panic!("Semantic workflow missing: {}", app.status) + }; + state +} +fn fixture() -> (Project, Id, Id, Id) { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::DataType, Layer::Oa, "Operational range", None) + .unwrap(); + let sa = p + .add_element(Kind::DataType, Layer::Sa, "System range", None) + .unwrap(); + for (id, min, max, unit) in [(oa, 0, 20, "m"), (sa, 1, 30, "cm")] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Integer { + minimum: Some(min), + maximum: Some(max), + }, + default: None, + unit: unit.into(), + }; + } + let trace = add_trace(&mut p, oa, sa, "Range rationale"); + (p, oa, sa, trace) +} +fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id { + let id = Id::new_v4(); + p.traces.insert( + id, + Trace { + id, + alias: format!("trace_{}", id.simple()), + name: name.into(), + description: String::new(), + oa, + sa, + kind: TraceKind::Manual, + baseline: None, + }, + ); + id +} +fn start(app: &mut App, source: Id) { + let _ = app.update(Message::StartSemanticReview(source)); +} +fn facet(value: DataFacet) -> FacetSelector { + FacetSelector::Data(value) +} +fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) { + send(app, Event::Select(selected.clone())); + send(app, Event::Decide(selected, Some(decision))); +} + +#[test] +fn scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions() { + for reverse in [false, true] { + let (p, oa, sa, trace) = fixture(); + let mut app = App::with_project(p); + let mut bundle = app.document.bundle().clone(); + bundle + .files + .get_mut(&bundle.entry) + .unwrap() + .insert_str(0, "// Retain this engineering rationale.\r\n"); + app.document.apply_bundle(bundle).unwrap(); + let before = app.document.snapshot(); + let revision = app.document.revision(); + let (source, target, prefix, expected_max, kept_min) = if reverse { + (sa, oa, "/body/Oa/Shared/DataType", 30, 0) + } else { + (oa, sa, "/body/Sa/Shared/DataType", 20, 1) + }; + start(&mut app, source); + decide( + &mut app, + facet(DataFacet::IntegerMinimum), + SemanticDecision::KeepTarget, + ); + decide( + &mut app, + facet(DataFacet::IntegerMaximum), + SemanticDecision::TakeSource, + ); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()), + ); + let prepared = + prepare_semantic_transition(app.project(), state(&app).plan.as_ref().unwrap()) + .unwrap() + .project; + assert!(state(&app).can_apply(), "{:?}", state(&app).error); + assert_eq!( + app.project(), + &before.decoded.project, + "Preview must not mutate accepted state" + ); + apply(&mut app); + assert!(app.workflow.is_none(), "{}", app.status); + assert_eq!( + app.project(), + &prepared, + "Apply must equal the exact reviewed project including diagrams" + ); + assert_eq!(app.document.revision(), revision + 1); + let Some(SharedBody::DataType { + definition: DataTypeDefinition::Integer { minimum, maximum }, + unit, + .. + }) = app.project().elements[&target].shared() + else { + panic!() + }; + assert_eq!((*minimum, *maximum), (Some(kept_min), Some(expected_max))); + assert_eq!(unit, "kg\n exact suffix "); + let baseline = app.project().traces[&trace] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap(); + assert_eq!(baseline.facets.len(), 3, "Unselected facts stay unreviewed"); + assert!( + app.document.bundle().files[&app.document.bundle().entry] + .contains("// Retain this engineering rationale.") + ); + let accepted = app.document.snapshot(); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.project(), &before.decoded.project); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &accepted.bundle); + assert_eq!(app.project(), &accepted.decoded.project); + } +} + +#[test] +fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() { + let (p, oa, _, _) = fixture(); + let mut app = App::with_project(p); + let before = app.document.snapshot(); + start(&mut app, oa); + decide( + &mut app, + facet(DataFacet::IntegerMinimum), + SemanticDecision::Candidate, + ); + let path = "/body/Sa/Shared/DataType/definition/Integer/minimum"; + send(&mut app, Event::Input(path.into(), "-".into())); + assert!(!state(&app).can_apply()); + send(&mut app, Event::Tab(Tab::References)); + assert!( + state(&app).error.is_some(), + "Navigation must retain visible validation errors" + ); + apply(&mut app); + assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-"); + assert_eq!(app.document.bundle(), &before.bundle); + send(&mut app, Event::Input(path.into(), "2".into())); + send(&mut app, Event::Input("/alias".into(), "unexpected".into())); + assert!(!state(&app).can_apply()); + assert!( + state(&app) + .error + .as_ref() + .unwrap() + .contains("selected scalar") + ); + assert_eq!(app.project(), &before.decoded.project); + send(&mut app, Event::Preview); + assert!(state(&app).can_apply()); +} + +#[test] +fn explicit_trace_selection_updates_only_that_history() { + let (mut p, oa, sa, first) = fixture(); + let second = add_trace(&mut p, oa, sa, "Independent assessment"); + p.traces.get_mut(&second).unwrap().kind = TraceKind::Item; + let mut app = App::with_project(p); + start(&mut app, oa); + assert!(state(&app).trace.is_none()); + assert!(!state(&app).can_apply()); + send( + &mut app, + Event::Trace(Choice { + id: second, + label: "Independent assessment".into(), + }), + ); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::KeepTarget, + ); + apply(&mut app); + assert!(app.workflow.is_none(), "{}", app.status); + assert!( + app.project().traces[&first] + .baseline + .as_ref() + .and_then(|b| b.semantic.as_ref()) + .is_none() + ); + assert!( + app.project().traces[&second] + .baseline + .as_ref() + .unwrap() + .semantic + .is_some() + ); +} + +#[test] +fn stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history() { + let (p, oa, _, _) = fixture(); + let mut app = App::with_project(p); + start(&mut app, oa); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::KeepTarget, + ); + app.document + .execute(Command::Rename(oa, "Changed outside preview".into())) + .unwrap(); + let before = app.document.snapshot(); + let revision = app.document.revision(); + apply(&mut app); + assert!(state(&app).error.as_ref().unwrap().contains("stale")); + assert_eq!(state(&app).decisions.len(), 1); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.document.revision(), revision); + + let (p, oa, _, _) = fixture(); + let mut app = App::with_project(p); + let bundle = app.document.bundle().clone(); + let mut limits = source_project::ProjectLimits::default(); + limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::() + 10; + app.document = Document::from_bundle(bundle, limits).unwrap(); + let before = app.document.snapshot(); + start(&mut app, oa); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Input( + "/body/Sa/Shared/DataType/unit".into(), + "long final unit ".repeat(40), + ), + ); + assert!(state(&app).can_apply()); + apply(&mut app); + assert!(state(&app).error.is_some()); + assert_eq!(state(&app).decisions.len(), 1); + assert_eq!(app.document.bundle(), &before.bundle); + assert_eq!(app.document.revision(), 0); + assert!(!app.document.can_undo()); +} + +#[test] +fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessing() { + let (mut p, oa, sa, _) = fixture(); + let parent_oa = p + .add_element(Kind::DataType, Layer::Oa, "OA parent", None) + .unwrap(); + let parent_sa = p + .add_element(Kind::DataType, Layer::Sa, "SA parent", None) + .unwrap(); + for id in [parent_oa, parent_sa] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: None }, + default: None, + unit: String::new(), + }; + } + // ExchangeItem supertypes are an independent optional typed reference. + for (id, parent) in [(oa, Some(parent_oa)), (sa, None)] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: parent }, + default: None, + unit: String::new(), + }; + } + let mut app = App::with_project(p); + start(&mut app, oa); + decide( + &mut app, + facet(DataFacet::RecordSupertype), + SemanticDecision::TakeSource, + ); + assert!(!state(&app).can_apply()); + send( + &mut app, + Event::ReferenceOa(Choice { + id: parent_oa, + label: "OA parent".into(), + }), + ); + send( + &mut app, + Event::ReferenceSa(Choice { + id: parent_sa, + label: "SA parent".into(), + }), + ); + send(&mut app, Event::AddReference); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Input( + "/body/Sa/Shared/DataType/unit".into(), + parent_oa.to_string(), + ), + ); + assert!(state(&app).can_apply(), "{:?}", state(&app).error); + apply(&mut app); + let Some(SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + unit, + .. + }) = app.project().elements[&sa].shared() + else { + panic!() + }; + assert_eq!(*supertype, Some(parent_sa)); + assert_eq!( + unit, + &parent_oa.to_string(), + "UUID-shaped prose must not be remapped" + ); +} + +#[test] +fn read_only_structured_facts_and_partial_rollup_are_honest() { + let (p, oa, _, trace) = fixture(); + let mut app = App::with_project(p); + start(&mut app, oa); + send( + &mut app, + Event::Decide( + facet(DataFacet::Constructor), + Some(SemanticDecision::TakeSource), + ), + ); + assert!(state(&app).decisions.is_empty()); + assert!(state(&app).error.is_some()); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::KeepTarget, + ); + apply(&mut app); + let rollup = trace_semantic_drift( + app.project(), + &app.project().traces[&trace], + &BTreeMap::new(), + ) + .unwrap(); + assert!(rollup.own_unreviewed); + assert!(summary(app.project(), &app.project().traces[&trace]).contains("Unreviewed")); + let mut request = State::new(app.project(), trace).unwrap(); + request.update( + app.project(), + Event::Select(facet(DataFacet::IntegerMaximum)), + ); + let _ = request.view(app.project()); + assert_eq!( + scalar::value( + &capture_facet( + app.project(), + oa, + request.target(app.project()).unwrap(), + &facet(DataFacet::Default) + ) + .unwrap(), + Layer::Oa + ), + Some(json!(null)) + ); +} + +#[test] +fn first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves() { + let (p, oa, _, trace) = fixture(); + let mut app = App::with_project(p); + start(&mut app, oa); + assert!(state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor))); + decide( + &mut app, + facet(DataFacet::Constructor), + SemanticDecision::KeepTarget, + ); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()), + ); + assert!( + state(&app).can_apply(), + "{:?}", + state(&app).plan.as_ref().map(|p| &p.conflicts) + ); + apply(&mut app); + let facts = &app.project().traces[&trace] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets; + assert_eq!(facts.len(), 2); + assert!( + facts + .iter() + .any(|f| f.selector() == facet(DataFacet::Constructor)) + ); + assert!( + !facts + .iter() + .any(|f| f.selector() == facet(DataFacet::IntegerMinimum)) + ); + start(&mut app, oa); + assert!( + !state(&app).can_acknowledge_constructor(app.project(), &facet(DataFacet::Constructor)) + ); + let remaining: Vec<_> = state(&app) + .facts + .iter() + .filter(|f| f.drift == Drift::Unreviewed) + .map(|f| f.selector.clone()) + .collect(); + for selector in remaining { + decide(&mut app, selector, SemanticDecision::KeepTarget); + } + apply(&mut app); + assert!(app.workflow.is_none(), "{}", app.status); + assert!( + !trace_semantic_drift( + app.project(), + &app.project().traces[&trace], + &BTreeMap::new() + ) + .unwrap() + .own_unreviewed + ); +} + +#[test] +fn state_kind_scalar_uses_typed_candidate_and_core_validation() { + let mut p = Project::empty(); + let mut pair = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let machine = p + .add_element(Kind::StateMachine, layer, "Machine", None) + .unwrap(); + let region = p + .add_element(Kind::Region, layer, "Region", Some(machine)) + .unwrap(); + pair.push( + p.add_element(Kind::State, layer, "Ready", Some(region)) + .unwrap(), + ); + } + add_trace(&mut p, pair[0], pair[1], "State review"); + let mut app = App::with_project(p); + start(&mut app, pair[0]); + decide( + &mut app, + FacetSelector::State(StateFacet::Kind), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")), + ); + assert!( + state(&app).can_apply(), + "{:?}", + state(&app).plan.as_ref().map(|p| &p.conflicts) + ); + let expected = state(&app).proposed.clone().unwrap(); + apply(&mut app); + assert_eq!(app.project(), &expected); + assert_eq!(app.project().elements[&pair[1]].kind(), Kind::Final); +} + +#[test] +fn relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds() { + let mut p = Project::empty(); + let mut pair = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let a = p + .add_element(Kind::DataType, layer, "Origin type", None) + .unwrap(); + let b = p + .add_element(Kind::DataType, layer, "Destination type", None) + .unwrap(); + for id in [a, b] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype: None }, + default: None, + unit: String::new(), + }; + } + let relation = p.relate(RelationKind::DataAssociation, "Association", a, b); + if let RelationBody::DataAssociation { source_role, .. } = + &mut p.relations.get_mut(&relation).unwrap().body + { + *source_role = if layer == Layer::Oa { + "Authored source role" + } else { + "Local role" + } + .into(); + } + pair.push(relation); + } + add_trace(&mut p, pair[0], pair[1], "Association review"); + let original = p.relations[&pair[1]].clone(); + let mut app = App::with_project(p); + start(&mut app, pair[0]); + decide( + &mut app, + FacetSelector::Relation(RelationFacet::SourceRole), + SemanticDecision::TakeSource, + ); + decide( + &mut app, + FacetSelector::Relation(RelationFacet::TargetMaximum), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Shape("/body/DataAssociation/target_maximum".into(), json!(4)), + ); + assert!(state(&app).can_apply(), "{:?}", state(&app).error); + apply(&mut app); + let accepted = &app.project().relations[&pair[1]]; + assert_eq!(accepted.alias, original.alias); + assert_eq!( + (accepted.source(), accepted.target()), + (original.source(), original.target()) + ); + let RelationBody::DataAssociation { + source_role, + target_maximum, + .. + } = &accepted.body + else { + panic!() + }; + assert_eq!(source_role, "Authored source role"); + assert_eq!(*target_maximum, Some(4)); +} + +#[test] +fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected_scalar() { + let (p, oa, sa, _) = fixture(); + let mut app = App::with_project(p); + start(&mut app, oa); + let path = "/body/Sa/Shared/DataType/definition/Integer/minimum"; + decide( + &mut app, + facet(DataFacet::IntegerMinimum), + SemanticDecision::Candidate, + ); + send(&mut app, Event::Input(path.into(), "-".into())); + decide( + &mut app, + facet(DataFacet::IntegerMinimum), + SemanticDecision::KeepTarget, + ); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::Candidate, + ); + send( + &mut app, + Event::Input( + "/body/Sa/Shared/DataType/unit".into(), + "reviewed local".into(), + ), + ); + assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-"); + assert!(state(&app).can_apply(), "{:?}", state(&app).error); + apply(&mut app); + let Some(SharedBody::DataType { + definition: DataTypeDefinition::Integer { minimum, .. }, + unit, + .. + }) = app.project().elements[&sa].shared() + else { + panic!() + }; + assert_eq!(*minimum, Some(1)); + assert_eq!(unit, "reviewed local"); +} + +#[test] +fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approval() { + let mut p = Project::empty(); + let oa = p + .add_element(Kind::DataType, Layer::Oa, "Operational record", None) + .unwrap(); + let sa = p + .add_element(Kind::DataType, Layer::Sa, "System record", None) + .unwrap(); + let base = p + .add_element(Kind::DataType, Layer::Sa, "Inherited record", None) + .unwrap(); + for (id, supertype) in [(oa, None), (sa, Some(base)), (base, None)] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Record { supertype }, + default: None, + unit: String::new(), + }; + } + let leaf = p + .add_element(Kind::DataField, Layer::Sa, "Inherited value", Some(base)) + .unwrap(); + let item = p + .add_element(Kind::ExchangeItem, Layer::Sa, "Telemetry", None) + .unwrap(); + let root = p + .add_element(Kind::DataField, Layer::Sa, "Position", Some(item)) + .unwrap(); + for (id, type_ref) in [(leaf, TypeRef::Real), (root, TypeRef::Named(sa))] { + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField { + type_ref, + minimum: 0, + maximum: Some(1), + unit: String::new(), + direction: ParameterDirection::Unspecified, + storage: FieldStorage::Value, + default: None, + }; + } + let a = p + .add_element(Kind::Function, Layer::Sa, "Send", None) + .unwrap(); + let b = p + .add_element(Kind::Function, Layer::Sa, "Receive", None) + .unwrap(); + let exchange = p.relate(RelationKind::FunctionalExchange, "Telemetry exchange", a, b); + let (source_port, target_port) = match &mut p.relations.get_mut(&exchange).unwrap().body { + RelationBody::FunctionalExchange { + source_port, + target_port, + items, + .. + } => { + items.push(item); + (*source_port, *target_port) + } + _ => panic!(), + }; + for id in [source_port, target_port] { + match p.elements.get_mut(&id).unwrap().shared_mut().unwrap() { + SharedBody::Port { + contract: PortContract::FunctionInput { incoming_items }, + .. + } => incoming_items.push(item), + SharedBody::Port { + contract: PortContract::FunctionOutput { outgoing_items }, + .. + } => outgoing_items.push(item), + _ => panic!(), + } + } + let scenario = p + .add_element(Kind::Scenario, Layer::Sa, "Telemetry scenario", None) + .unwrap(); + let message = p + .add_element(Kind::Occurrence, Layer::Sa, "Send position", Some(scenario)) + .unwrap(); + *p.elements.get_mut(&message).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: Some(exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![MessageValue { + path: vec![root], + field: leaf, + expression: "current position".into(), + }], + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .push(message); + add_trace(&mut p, oa, sa, "Record inheritance review"); + let mut app = App::with_project(p); + let before = app.document.snapshot(); + start(&mut app, oa); + decide( + &mut app, + facet(DataFacet::RecordSupertype), + SemanticDecision::TakeSource, + ); + assert!(!state(&app).can_apply()); + let impacts = &state(&app).plan.as_ref().unwrap().impacts; + assert_eq!(impacts.len(), 1); + assert_eq!(impacts[0].object, message); + let exact = impacts[0].clone(); + send(&mut app, Event::AcceptImpact(0, true)); + assert!( + state(&app).can_apply(), + "{:?}", + state(&app).plan.as_ref().map(|p| &p.conflicts) + ); + assert_eq!( + state(&app).plan.as_ref().unwrap().request.accepted_impacts, + vec![exact] + ); + decide( + &mut app, + facet(DataFacet::Unit), + SemanticDecision::KeepTarget, + ); + assert!( + state(&app) + .plan + .as_ref() + .unwrap() + .request + .accepted_impacts + .is_empty() + ); + assert!(!state(&app).can_apply()); + send(&mut app, Event::AcceptImpact(0, true)); + let expected = state(&app).proposed.clone().unwrap(); + assert_eq!(app.document.bundle(), &before.bundle); + apply(&mut app); + assert_eq!(app.project(), &expected); + let Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { values, .. }, + .. + }) = app.project().elements[&message].shared() + else { + panic!() + }; + assert!(values.is_empty()); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); +} diff --git a/crates/oa-desktop/src/semantic_review/view.rs b/crates/oa-desktop/src/semantic_review/view.rs new file mode 100644 index 00000000..f8443fa4 --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/view.rs @@ -0,0 +1,580 @@ +use super::*; +use crate::{style, views::short}; +use iced::{ + Element, Fill, + widget::{ + Space, button, checkbox, column, container, pick_list, row, scrollable, text, text_input, + }, +}; + +fn event(event: Event) -> Message { + Message::Workflow(crate::workflow::Event::Semantic(event)) +} +fn action<'a>(label: impl Into, event_value: Event) -> iced::widget::Button<'a, Message> { + button(text(label.into()).size(12)) + .padding([7, 9]) + .style(style::nav) + .on_press(event(event_value)) +} +pub(super) fn choice(project: &Project, id: Id) -> Choice { + let alias = project + .elements + .get(&id) + .map(|v| v.alias.as_str()) + .or_else(|| project.relations.get(&id).map(|v| v.alias.as_str())) + .or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str())); + Choice { + id, + label: alias.map_or_else( + || format!("Deleted reference · {id}"), + |alias| format!("{} · @{alias}", project.named(id)), + ), + } +} +fn words(value: &str) -> String { + let mut out = String::new(); + for (index, c) in value.chars().enumerate() { + if c == '_' { + out.push(' '); + } else { + if index > 0 && c.is_uppercase() { + out.push(' '); + } + out.push(c); + } + } + out +} +pub(super) fn group(facet: &FacetSelector) -> &'static str { + match facet { + FacetSelector::Header(_) => "Name and ownership", + FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => { + "Data and defaults" + } + FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => { + "Contracts and interfaces" + } + FacetSelector::Scenario(_) + | FacetSelector::Lifeline(_) + | FacetSelector::Occurrence(_) + | FacetSelector::Event(_) + | FacetSelector::State(_) + | FacetSelector::Annotation(_) + | FacetSelector::Binding(_) => "Behavior and scenarios", + FacetSelector::Relation(_) | FacetSelector::Constraint(_) => { + "Relationships and constraints" + } + FacetSelector::Member { .. } | FacetSelector::Order(_) => "Membership and order", + FacetSelector::Child(_) + | FacetSelector::Incident { .. } + | FacetSelector::Artifact { .. } => "Attached records", + } +} +pub(super) fn title(project: &Project, facet: &FacetSelector) -> String { + let keys = |keys: &KeyPair| { + format!( + "{} ↔ {}", + keys.oa.map(|id| project.named(id)).unwrap_or("Absent"), + keys.sa.map(|id| project.named(id)).unwrap_or("Absent") + ) + }; + match facet { + FacetSelector::Member { + collection, + keys: pair, + } => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)), + FacetSelector::Order(collection) => format!("{} order", words(&format!("{collection:?}"))), + FacetSelector::Literal(pair) => format!( + "Literal · {} ↔ {}", + pair.oa.as_deref().unwrap_or("Absent"), + pair.sa.as_deref().unwrap_or("Absent") + ), + FacetSelector::Annotation(pair) => format!( + "Message value · {} ↔ {}", + pair.oa + .as_ref() + .map(|key| project.named(key.field)) + .unwrap_or("Absent"), + pair.sa + .as_ref() + .map(|key| project.named(key.field)) + .unwrap_or("Absent") + ), + FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)), + FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)), + FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)), + FacetSelector::Artifact { keys: pair, facet } => { + format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair)) + } + _ => { + let json = serde_json::to_value(facet).expect("Typed selector"); + let value = json + .as_object() + .expect("Family") + .values() + .next() + .expect("Leaf"); + words(value.as_str().unwrap_or("Property")) + } + } +} +fn drift(drift: Drift) -> &'static str { + match drift { + Drift::Unreviewed => "Unreviewed", + Drift::Unchanged => "Unchanged since review", + Drift::OaChanged => "Operational changed", + Drift::SaChanged => "System changed", + Drift::BothChanged => "Both changed", + } +} +pub(crate) fn summary(project: &Project, trace: &Trace) -> String { + let references = BTreeMap::from([(trace.oa, trace.sa)]); + match trace_semantic_drift(project, trace, &references) { + Ok(value) => format!( + "Semantics: {}{} · Linked: {}{}", + drift(value.own), + if value.own_unreviewed && value.own != Drift::Unreviewed { + " · incomplete review" + } else { + "" + }, + drift(value.linked), + if value.linked_unreviewed && value.linked != Drift::Unreviewed { + " · incomplete review" + } else { + "" + } + ), + Err(error) => format!("Semantic review unavailable: {error}"), + } +} +fn formatted(project: &Project, value: &Value, references: &BTreeSet) -> String { + match value { + Value::Null => "Not set".into(), + Value::Bool(value) => if *value { "Yes" } else { "No" }.into(), + Value::Number(value) => value.to_string(), + Value::String(value) => { + if let Ok(id) = value.parse::() + && references.contains(&id) + { + choice(project, id).label + } else if value.is_empty() { + "Empty text".into() + } else { + format!("“{value}”") + } + } + Value::Array(values) if values.is_empty() => "Empty list (0 members)".into(), + Value::Array(values) => values + .iter() + .enumerate() + .map(|(i, v)| format!("{}. {}", i + 1, formatted(project, v, references))) + .collect::>() + .join("\n"), + Value::Object(values) => values + .iter() + .map(|(key, value)| { + format!("{}: {}", words(key), formatted(project, value, references)) + }) + .collect::>() + .join("\n"), + } +} +fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> String { + let Some(fact) = fact else { + return "Unreviewed — no previous observation".into(); + }; + let value = scalar::observation(fact, layer); + if value.as_str() == Some("NotApplicable") { + "Not applicable".into() + } else { + formatted(project, &value["Value"], &scalar::references(fact)) + } +} + +impl State { + pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let trace = self.trace.and_then(|id| project.traces.get(&id)); + let mut traces: Vec<_> = project + .traces + .values() + .filter(|t| t.oa == self.anchor || t.sa == self.anchor) + .map(|t| Choice { + id: t.id, + label: format!( + "{} · {:?} · {} ↔ {} · @{}", + t.name, + t.kind, + project.named(t.oa), + project.named(t.sa), + t.alias + ), + }) + .collect(); + traces.sort_by(|a, b| a.label.cmp(&b.label)); + let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned(); + let direction = if self.direction == TransitionDirection::OaToSa { + "Use Operational changes in System" + } else { + "Use System changes in Operational" + }; + let mut content = column![ + text("Review semantics").size(22), + row![ + pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill), + action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })), + ].spacing(8), + text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED), + row![action("Properties", Event::Tab(Tab::Facts)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6), + ].spacing(8); + let body = match self.tab { + Tab::Facts => self.facts_view(project), + Tab::References => self.references_view(project), + Tab::Impacts => self.impacts_view(project), + }; + content = content.push(body); + if let Some(error) = &self.error { + content = content.push( + text(error) + .size(12) + .color(iced::Color::from_rgb8(178, 54, 58)), + ); + } + if let Some(plan) = &self.plan { + for error in &plan.conflicts { + content = content.push( + text(error) + .size(12) + .color(iced::Color::from_rgb8(178, 54, 58)), + ); + } + } + let unreviewed = self + .facts + .iter() + .filter(|f| f.drift == Drift::Unreviewed) + .count(); + content = content.push(text(format!("{} selected decisions · {unreviewed} currently unreviewed facts · Constructors and structured collections require further review.", 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)); + } + content + .push( + row![ + text( + "Apply updates the selected history and model together; Undo restores both." + ) + .size(10) + .width(Fill), + button(text("Cancel").size(12)) + .padding(8) + .style(style::nav) + .on_press(Message::Workflow(crate::workflow::Event::Cancel)), + button(text("Apply reviewed changes").size(12)) + .padding(8) + .on_press_maybe( + self.can_apply() + .then_some(Message::Workflow(crate::workflow::Event::Apply)) + ), + ] + .spacing(8) + .align_y(iced::Center), + ) + .into() + } + + fn facts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let mut list = column![ + checkbox(self.show_unchanged) + .label("Include unchanged facts") + .text_size(11) + .on_toggle(|v| event(Event::ShowUnchanged(v))) + ] + .spacing(5); + let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new(); + for fact in &self.facts { + if self.show_unchanged + || fact.drift != Drift::Unchanged + || self.decisions.contains_key(&fact.selector) + || self.selected.as_ref() == Some(&fact.selector) + { + groups.entry(group(&fact.selector)).or_default().push(fact); + } + } + for (group, facts) in groups { + list = list.push(text(group).size(12).color(style::TEAL)); + for fact in facts { + let mark = match self.decisions.get(&fact.selector) { + Some(SemanticDecision::KeepTarget) => "Keep · ", + Some(SemanticDecision::TakeSource) => "Take · ", + Some(SemanticDecision::Candidate) => "Final value · ", + None => "", + }; + list = list.push( + button( + column![ + text(short(&title(project, &fact.selector), 52)).size(12), + text(format!("{mark}{}", drift(fact.drift))).size(10) + ] + .spacing(3), + ) + .padding(7) + .width(Fill) + .style(if self.selected.as_ref() == Some(&fact.selector) { + style::selected + } else { + style::nav + }) + .on_press(event(Event::Select(fact.selector.clone()))), + ); + } + } + row![ + scrollable(list).height(305).width(260), + scrollable(self.detail(project)).height(305).width(Fill) + ] + .spacing(12) + .into() + } + + fn detail<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let Some(fact) = self + .selected + .as_ref() + .and_then(|s| self.facts.iter().find(|f| &f.selector == s)) + else { + return text("Choose a trace and property to compare.") + .size(13) + .into(); + }; + let trace = self.trace.and_then(|id| project.traces.get(&id)); + let 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() == fact.selector)); + let side = |layer, label: &str| { + column![ + text(format!( + "{label}{}", + if layer == self.direction.target_layer() { + " · destination" + } else { + " · source" + } + )) + .size(12) + .color(style::TEAL), + text("Previously reviewed").size(10).color(style::MUTED), + text(observed(project, previous, layer)).size(12), + text("Current").size(10).color(style::MUTED), + text(observed(project, Some(&fact.current), layer)).size(12), + ] + .spacing(5) + .width(Fill) + }; + let mut detail = column![ + text(title(project, &fact.selector)).size(17), + text(drift(fact.drift)).size(11), + row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14) + ] + .spacing(10); + if let Some(proposed) = &self.proposed + && let Some(trace) = trace + && let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector) + { + detail = detail + .push( + text("Proposed destination value · validated preview") + .size(11) + .color(style::TEAL), + ) + .push( + text(observed( + proposed, + Some(&after), + self.direction.target_layer(), + )) + .size(12), + ); + } else { + detail = detail.push( + text("No validated final preview. Resolve the messages below before applying.") + .size(11) + .color(style::MUTED), + ); + } + if self.editable(project, &fact.selector) { + let destination = if self.direction.target_layer() == Layer::Sa { + "System" + } else { + "Operational" + }; + let source = if self.direction.source_layer() == Layer::Oa { + "Operational" + } else { + "System" + }; + let mut buttons = row![ + action( + format!("Keep {destination}"), + Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget)) + ), + action( + format!("Take {source}"), + Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource)) + ) + ] + .spacing(5); + buttons = buttons.push(action( + "Set final value…", + Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)), + )); + detail = detail.push(buttons).push(action( + "Leave unselected", + Event::Decide(fact.selector.clone(), None), + )); + if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) + && let Some(draft) = &self.draft + && let Some(path) = self + .target(project) + .and_then(|id| scalar::binding(project, id, &fact.selector)) + { + detail = detail.push(text("Final destination value").size(12)).push( + crate::forms::scoped(draft, project, &path, &title(project, &fact.selector)) + .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)); + } + if matches!( + fact.selector, + FacetSelector::Child(_) + | FacetSelector::Incident { .. } + | FacetSelector::Artifact { .. } + ) { + detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED)); + } + detail.into() + } + + fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let mut content = column![ + text("Reference correspondence · always Operational → System").size(15), + text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires reset in the structured workflow.").size(12), + text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12), + row![ + pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill), + pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill), + action("Use pair", Event::AddReference), + ].spacing(7), + ].spacing(9); + for (oa, sa) in &self.references { + content = content.push( + row![ + text(format!( + "{}\n↔ {}", + choice(project, *oa).label, + choice(project, *sa).label + )) + .size(12) + .width(Fill), + action("Remove pair", Event::RemoveReference(*oa)) + ] + .spacing(6), + ); + } + if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) { + content = content + .push(text("Referenced definitions · reviewed on their own traces").size(14)); + match linked_semantic_impacts(project, trace, &self.references) { + Ok(impacts) => { + for impact in impacts { + content = content.push( + text(format!( + "{} ↔ {} · {}{} · {} trace(s)", + impact + .objects + .oa + .map(|id| choice(project, id).label) + .unwrap_or_else(|| "Absent".into()), + impact + .objects + .sa + .map(|id| choice(project, id).label) + .unwrap_or_else(|| "Absent".into()), + drift(impact.drift), + if impact.unreviewed { + " · incomplete review" + } else { + "" + }, + impact.traces.len() + )) + .size(12), + ); + } + } + Err(error) => content = content.push(text(error).size(12)), + } + } + scrollable(content).height(305).into() + } + + fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let mut content = column![text("Additional prepared changes").size(16), text("Accept each exact change required by model preparation. Acceptance never overrides a selected Keep or Take. Editing a decision clears these approvals.").size(12)].spacing(10); + if let Some(plan) = &self.plan { + if plan.impacts.is_empty() { + content = + content.push(text("No additional prepared changes in this preview.").size(12)); + } + for (index, impact) in plan.impacts.iter().enumerate() { + let before = impact + .before + .as_ref() + .map(|r| serde_json::to_value(r).expect("Typed impact")); + let after = impact + .after + .as_ref() + .map(|r| serde_json::to_value(r).expect("Typed impact")); + let ids = project.identities().into_iter().map(|(id, _)| id).collect(); + content = content.push( + container( + column![ + checkbox(self.accepted_impacts.contains(impact)) + .label(format!("Accept · {}", project.named(impact.object))) + .text_size(12) + .on_toggle(move |accepted| event(Event::AcceptImpact( + index, accepted + ))), + text("Before").size(11).color(style::MUTED), + text( + before + .as_ref() + .map(|v| formatted(project, v, &ids)) + .unwrap_or_else(|| "Absent · record creation".into()) + ) + .size(12), + text("After").size(11).color(style::MUTED), + text( + after + .as_ref() + .map(|v| formatted(project, v, &ids)) + .unwrap_or_else(|| "Absent · record deletion".into()) + ) + .size(12), + ] + .spacing(6), + ) + .padding(10) + .style(style::subtle), + ); + } + } + scrollable(content).height(305).into() + } +} diff --git a/crates/oa-desktop/src/tests.rs b/crates/oa-desktop/src/tests.rs index e18507a8..fe0476fd 100644 --- a/crates/oa-desktop/src/tests.rs +++ b/crates/oa-desktop/src/tests.rs @@ -250,7 +250,10 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() { assert_eq!(app.page, Page::Validation); assert_eq!(app.project(), &original); assert!(!app.dirty()); - assert!(app.source_snapshot.starts_with("arcadia 4;")); + assert!( + app.source_snapshot + .starts_with(&format!("arcadia {SCHEMA_VERSION};")) + ); assert!(serde_json::from_slice::(&bytes).unwrap()["schema_version"] == 2); } diff --git a/crates/oa-desktop/src/views.rs b/crates/oa-desktop/src/views.rs index 34c967a9..88719043 100644 --- a/crates/oa-desktop/src/views.rs +++ b/crates/oa-desktop/src/views.rs @@ -247,6 +247,13 @@ pub fn view(app: &App) -> View<'_, Message> { .style(style::panel); let root: View<'_, Message> = column![header, task_nav, body, footer].into(); if let Some(workflow) = &app.workflow { + if matches!(workflow, crate::workflow::Workflow::Semantic(_)) { + return modal_width( + root, + workflow.view(p), + (app.window_size.width - 50.).clamp(800., 1120.), + ); + } return modal(root, workflow.view(p)); } if let Some(p) = &app.file_prompt { @@ -299,15 +306,27 @@ pub fn view(app: &App) -> View<'_, Message> { root } fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> { + modal_width(base, content, 800.) +} +fn modal_width<'a>( + base: View<'a, Message>, + content: View<'a, Message>, + width: f32, +) -> View<'a, Message> { widget::stack![ base, widget::opaque( - container(container(content).padding(26).width(800).style(style::card)) - .center(Fill) - .style(|_| container::Style { - background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()), - ..Default::default() - }) + container( + container(content) + .padding(26) + .width(width) + .style(style::card) + ) + .center(Fill) + .style(|_| container::Style { + background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()), + ..Default::default() + }) ) ] .into() @@ -714,10 +733,14 @@ fn traces(app: &App) -> View<'_, Message> { "Connect operational needs, system responsibilities and development evidence." ), row![ - button(text("Create / update counterpart…").size(12)) + button(text("Create counterpart / review prose…").size(12)) .padding(9) .on_press(Message::StartTransition), action("+ Manual trace", Message::AddTrace), + action( + "Review semantics…", + Message::StartSemanticReview(app.selected) + ), action("+ Artifact", Message::AddArtifact) ] .spacing(8) @@ -740,10 +763,14 @@ fn traces(app: &App) -> View<'_, Message> { .spacing(8) .align_y(iced::Center), row![ - label(format!("{:?} · {current}", t.kind)), + label(format!("{:?} · Prose: {current}", t.kind)), Space::new().width(Fill), - action("Review link", Message::Select(t.id)) + action("Review semantics…", Message::StartSemanticReview(t.id)), + action("Edit link", Message::Select(t.id)) ], + text(crate::semantic_review::summary(p, t)) + .size(11) + .color(style::MUTED), text(short(&t.description, 130)).size(12) ] .spacing(10), diff --git a/crates/oa-desktop/src/workflow.rs b/crates/oa-desktop/src/workflow.rs index 0f8752a0..ec9c11fb 100644 --- a/crates/oa-desktop/src/workflow.rs +++ b/crates/oa-desktop/src/workflow.rs @@ -15,6 +15,7 @@ use std::collections::BTreeSet; #[derive(Debug, Clone)] pub enum Event { + Semantic(crate::semantic_review::Event), Port(crate::port_workflow::Event), ExchangeItem(Id, bool), Endpoint(bool, EndpointChoice), @@ -33,6 +34,7 @@ pub enum Event { Preview, } pub enum Workflow { + Semantic(Box), Ports(Box), Relation(Box), ManualTrace { @@ -333,6 +335,11 @@ impl Workflow { /// choices and previews never mutate the Session. pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { match self { + Self::Semantic(_) => { + return Err( + "Semantic review commits its exact command batch through Document.".into(), + ); + } Self::Ports(state) => return state.update(p, event), Self::ManualTrace { name, @@ -571,6 +578,7 @@ impl Workflow { } pub fn title(&self) -> &'static str { match self { + Self::Semantic(_) => "Review semantics", Self::Ports(state) => state.title(), Self::ManualTrace { .. } => "Link existing counterparts", Self::Relation(_) => "Create relationship", @@ -584,6 +592,9 @@ impl Workflow { } } pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> { + if let Self::Semantic(state) = self { + return state.view(p); + } if let Self::Ports(state) = self { return state.view(p); } @@ -594,7 +605,7 @@ impl Workflow { None }; match self { - Self::Ports(_) => unreachable!("Port dialog returned above"), + Self::Ports(_) | Self::Semantic(_) => unreachable!("Dedicated dialog returned above"), Self::ManualTrace { name, source, @@ -1073,6 +1084,7 @@ impl Workflow { } } let enabled = match self { + Self::Semantic(_) => false, Self::Ports(_) => false, Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(), Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok), diff --git a/harness/release-semantic-gui.md b/harness/release-semantic-gui.md new file mode 100644 index 00000000..3ff72baf --- /dev/null +++ b/harness/release-semantic-gui.md @@ -0,0 +1,82 @@ +# Semantic desktop review — milestones 1 and 2 + +Status: milestones 1–2 implemented and desktop tests/build verified; not full GUI +parity, native acceptance or release acceptance. +Base: `d130013`. Dedicated build target: +`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned. + +## Scope and decisions + +- Add an explicit Review semantics action for existing traced counterparts, with + canonical Operational/System comparisons in either application direction. + Preserve the existing counterpart creation and prose workflow with clear labels. +- Review one explicitly selected trace per dialog. Display grouped current and + historical facts, own/linked drift and incomplete review. Names never infer + reference correspondence; provide explicit reference and trace choices. +- 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. 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. +- 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. +- Show exact additional prepared impacts and require explicit acceptance. Apply + validates the stored semantic plan and executes its exact command batch through + Document, preserving source, baselines and one-step undo/redo. Do not place or + reveal diagram objects by mutating the model after preview. +- Retain typed drafts and review choices on invalid input, planning conflicts, + stale plans and failed source writeback. Display actionable errors in the dialog. + +## Milestones and acceptance + +1. Grouped read-only review, named trace/reference choices, exact comparisons and + honest traceability rollups; compact layout usable at 1100 by 700. +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. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle, + multi-source review and remaining source/REST parity cases. Still outstanding. +4. Root-owned native review, cross-surface fixtures, package/release acceptance. + +## Verification + +Verified against core `6bb2583`, using only the dedicated desktop target: + +```powershell +$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target' +cargo test --locked -p oa-desktop --quiet +cargo clippy --locked -p oa-desktop --all-targets -- -D warnings +cargo build --locked -p oa-desktop +``` + +All **272 desktop tests passed**, including **11 new semantic review tests**. +Strict Clippy and the desktop build passed. The existing migration test's header +assertion now follows `SCHEMA_VERSION` instead of expecting schema 4. + +Coverage includes both-direction Keep/Take/Candidate with exact comments and +source/model/baseline undo/redo; independent selected trace semantic histories; +invalid numeric and protected-property edits; stale-plan and source-writeback +rejection; typed reference mapping mixed with candidates; honest partial rollups; +equal-kind acknowledgment without implicit descendants; State.Kind candidates; +relationship role/bound candidates with retained identity/endpoints; and exact +inherited-annotation cleanup approval, cleared when decisions change. Unselected +invalid scalar drafts remain retained without contaminating another candidate. + +Built executable: +`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`. +The build target is idle. No native application was launched by this task. + +## Remaining work + +Milestone 3 must add explicit 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 +identified. The reference table supplies explicit mappings for scalar reference +Take; it does not claim to complete a reset or establish keyed acknowledgments. + +Fresh independent scalar GUI review and root-owned native verification are still +required before merging this milestone into release acceptance. In particular, +validate the 1100×700 layout, keyboard/focus continuity, long historical values, +reference selection, source reopening and prepared-impact inspection in the app.