diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index 21be7f81..da2ffacf 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -708,7 +708,12 @@ fn free_string(path: &str) -> bool { // These are choices for the closed model, not a second validation boundary. // A current reference is always retained, even while other draft fields make it // incompatible. Applying the complete draft remains the core validator's job. -fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec { +pub(crate) fn reference_choices( + draft: &Draft, + project: &Project, + path: &str, + current: Id, +) -> Vec { let identities = project.identities().into_iter().collect::>(); let compatible = reference_ids(draft, project, path); let mut objects = compatible diff --git a/crates/oa-desktop/src/semantic_review.rs b/crates/oa-desktop/src/semantic_review.rs index 5cbfe560..00788697 100644 --- a/crates/oa-desktop/src/semantic_review.rs +++ b/crates/oa-desktop/src/semantic_review.rs @@ -9,6 +9,8 @@ use serde_json::Value; use std::collections::{BTreeMap, BTreeSet}; mod constructors; +mod history; +mod keyed; mod message_kind; mod scalar; mod view; @@ -16,6 +18,8 @@ pub(crate) use view::summary; #[cfg(test)] mod constructor_tests; #[cfg(test)] +mod keyed_tests; +#[cfg(test)] mod message_kind_tests; #[cfg(test)] mod tests; @@ -24,6 +28,8 @@ mod tests; pub enum Tab { Facts, References, + Members, + History, Impacts, } @@ -49,6 +55,16 @@ pub enum Event { RemoveReference(Id), AcceptImpact(usize, bool), Preview, + Reset(FacetSelector, bool), + Presence(FacetSelector, bool), + MemberFamily(keyed::Family), + MemberOa(Option), + MemberSa(Option), + Literal(String), + LiteralSide(Layer, bool), + EditPair(FacetSelector), + StageMember, + DropStaged(FacetSelector), } pub struct State { @@ -59,6 +75,15 @@ pub struct State { pub selected: Option, pub decisions: BTreeMap, pub references: BTreeMap, + pub resets: BTreeSet, + pub staged: BTreeSet, + pub presence: BTreeMap, + pub member_family: keyed::Family, + pub member_oa: Option, + pub member_sa: Option, + pub literal: String, + pub literal_oa: bool, + pub literal_sa: bool, pub draft: Option, // Each closed constructor has an independent draft, including nested kinds. // Scalar and sibling drafts survive decision and constructor changes. @@ -104,6 +129,15 @@ impl State { selected: None, decisions: BTreeMap::new(), references: BTreeMap::new(), + resets: BTreeSet::new(), + staged: BTreeSet::new(), + presence: BTreeMap::new(), + member_family: keyed::Family::Literal, + member_oa: None, + member_sa: None, + literal: String::new(), + literal_oa: true, + literal_sa: true, draft: None, group_drafts: BTreeMap::new(), plan: None, @@ -132,6 +166,14 @@ impl State { fn load_trace(&mut self, project: &Project) { self.facts.clear(); self.decisions.clear(); + self.resets.clear(); + self.staged.clear(); + self.presence.clear(); + self.member_oa = None; + self.member_sa = None; + self.literal.clear(); + self.literal_oa = true; + self.literal_sa = true; self.selected = None; self.accepted_impacts.clear(); self.references.clear(); @@ -154,6 +196,11 @@ impl State { if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) { self.references.insert(trace.oa, trace.sa); } + self.member_family = self + .member_families(project) + .first() + .copied() + .unwrap_or(keyed::Family::Literal); self.refresh(project); } @@ -191,6 +238,17 @@ impl State { { Err("This fact needs the structured review workflow; its history remains unchanged.".into()) } else { + if keyed::supported(&facet) { + let target_presence = self + .effective_trace(project) + .and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok()) + .and_then(|f| scalar::value(&f, self.direction.target_layer())) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + self.presence + .entry(facet.clone()) + .or_insert(target_presence); + } self.decisions.insert(facet, decision); Ok(()) } @@ -274,6 +332,57 @@ impl State { return; } Event::Preview => Ok(()), + Event::Reset(facet, reset) => self.reset_history(project, facet, reset), + Event::Presence(facet, present) => { + if keyed::supported(&facet) + && self.decisions.get(&facet) == Some(&SemanticDecision::Candidate) + { + self.presence.insert(facet, present); + Ok(()) + } else { + Err("Choose Set final value for this member before editing presence.".into()) + } + } + Event::MemberFamily(family) => { + self.member_family = family; + replan = false; + Ok(()) + } + Event::MemberOa(choice) => { + self.member_oa = choice; + replan = false; + Ok(()) + } + Event::MemberSa(choice) => { + self.member_sa = choice; + replan = false; + Ok(()) + } + Event::Literal(value) => { + self.literal = value; + replan = false; + Ok(()) + } + Event::LiteralSide(layer, present) => { + if layer == Layer::Oa { + self.literal_oa = present; + } else { + self.literal_sa = present; + } + replan = false; + Ok(()) + } + Event::EditPair(facet) => { + replan = false; + self.edit_pair(project, facet) + } + Event::StageMember => self.stage_member(project), + Event::DropStaged(facet) => { + if self.staged.remove(&facet) { + self.decisions.remove(&facet); + } + Ok(()) + } }; if let Err(error) = result { self.error = Some(error); @@ -322,6 +431,11 @@ impl State { } pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool { + if keyed::supported(facet) { + return self + .effective_trace(project) + .is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok()); + } self.target(project) .and_then(|id| scalar::binding(project, id, facet)) .is_some() @@ -345,7 +459,7 @@ impl State { if !scalar::grouped(facet) { return false; } - let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else { + let Some(trace) = self.effective_trace(project) else { return false; }; if trace @@ -394,7 +508,7 @@ impl State { decision: *decision, }) .collect(), - reset: vec![], + reset: self.resets.iter().cloned().collect(), }); request.accepted_impacts = self.accepted_impacts.clone(); if self @@ -423,10 +537,7 @@ impl State { /// 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 { - let trace = self - .trace - .and_then(|id| project.traces.get(&id)) - .ok_or("Choose a trace.")?; + let trace = self.effective_trace(project).ok_or("Choose a trace.")?; let (source, target) = if self.direction == TransitionDirection::OaToSa { (trace.oa, trace.sa) } else { @@ -462,6 +573,10 @@ impl State { if *decision == SemanticDecision::KeepTarget { continue; } + if keyed::supported(facet) { + self.keyed_value(project, &trace, &mut final_value, facet, *decision)?; + continue; + } let roots = constructors::roots(project, target, facet) .ok_or("Selected property is not applicable to the target.")?; let supplied = match decision { @@ -480,7 +595,7 @@ impl State { if self.can_acknowledge_constructor(project, facet) { original.clone() } else { - constructors::take_value(project, trace, self.direction, facet, &map)? + constructors::take_value(project, &trace, self.direction, facet, &map)? } } SemanticDecision::TakeSource => { @@ -531,15 +646,43 @@ impl State { 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 { + let Some(trace) = self.effective_trace(project) 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, + match semantic_facets(project, &trace, &self.references) { + Ok(mut facts) => { + // Explicit addresses survive catalog normalization, pairing changes and resets. + for selector in self.staged.iter().chain(self.decisions.keys()) { + if !facts.iter().any(|f| &f.selector == selector) { + match capture_facet(project, trace.oa, trace.sa, selector) { + Ok(current) => { + let previous = trace + .baseline + .as_ref() + .and_then(|b| b.semantic.as_ref()) + .and_then(|b| { + b.facets.iter().find(|f| f.selector() == *selector) + }); + facts.push(FacetDrift { + selector: selector.clone(), + drift: facet_drift(¤t, previous), + current, + }); + } + Err(error) => { + self.error = Some(error); + return; + } + } + } + } + facts.sort_by(|a, b| a.selector.cmp(&b.selector)); + self.facts = facts; + } Err(error) => { self.error = Some(error); return; @@ -579,8 +722,11 @@ impl State { } pub fn command(&mut self, project: &Project) -> Result { - if self.decisions.is_empty() { - return Err("Choose at least one property to review.".into()); + if self.decisions.is_empty() && self.resets.is_empty() { + return Err( + "Choose at least one property to review or an exact history observation to reset." + .into(), + ); } if let Some(error) = &self.error { return Err(error.clone()); @@ -594,7 +740,7 @@ impl State { } pub fn can_apply(&self) -> bool { - !self.decisions.is_empty() + (!self.decisions.is_empty() || !self.resets.is_empty()) && self.error.is_none() && self .plan diff --git a/crates/oa-desktop/src/semantic_review/history.rs b/crates/oa-desktop/src/semantic_review/history.rs new file mode 100644 index 00000000..fbedc531 --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/history.rs @@ -0,0 +1,43 @@ +use super::*; + +pub(super) fn resettable(facet: &FacetSelector) -> bool { + !matches!(facet, FacetSelector::Artifact { .. }) +} + +impl State { + pub(super) fn effective_trace(&self, project: &Project) -> Option { + let mut trace = project.traces.get(&self.trace?)?.clone(); + if let Some(baseline) = trace.baseline.as_mut().and_then(|b| b.semantic.as_mut()) { + baseline + .facets + .retain(|f| !self.resets.contains(&f.selector())); + } + Some(trace) + } + + pub(super) fn reset_history( + &mut self, + project: &Project, + facet: FacetSelector, + reset: bool, + ) -> Result<(), String> { + if !reset && self.resets.remove(&facet) { + return Ok(()); + } + let exists = self + .trace + .and_then(|id| project.traces.get(&id)) + .and_then(|t| t.baseline.as_ref()) + .and_then(|b| b.semantic.as_ref()) + .is_some_and(|b| b.facets.iter().any(|f| f.selector() == facet)); + if !exists || !resettable(&facet) { + return Err("Choose an exact existing non-artifact observation to reset.".into()); + } + if reset { + self.resets.insert(facet); + } else { + self.resets.remove(&facet); + } + Ok(()) + } +} diff --git a/crates/oa-desktop/src/semantic_review/keyed.rs b/crates/oa-desktop/src/semantic_review/keyed.rs new file mode 100644 index 00000000..91595a4c --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/keyed.rs @@ -0,0 +1,263 @@ +//! Closed presence-only adapter. The core independently validates the full final record. +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Family { + Member(ReferenceCollection), + Literal, +} +impl std::fmt::Display for Family { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items", + Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items", + Self::Member(ReferenceCollection::SignalItems) => "Signal items", + Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items", + Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers", + Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects", + Self::Member(ReferenceCollection::Constrained) => "Constrained identities", + Self::Literal => "Enumeration literals", + _ => "Ordered membership (read-only)", + }) + } +} +pub(super) const FAMILIES: [Family; 8] = [ + Family::Member(ReferenceCollection::FunctionInputItems), + Family::Member(ReferenceCollection::FunctionOutputItems), + Family::Member(ReferenceCollection::SignalItems), + Family::Member(ReferenceCollection::ExchangeItems), + Family::Member(ReferenceCollection::TransitionTriggers), + Family::Member(ReferenceCollection::TransitionEffects), + Family::Member(ReferenceCollection::Constrained), + Family::Literal, +]; +pub(super) fn family(facet: &FacetSelector) -> Option { + let family = match facet { + FacetSelector::Member { collection, .. } => Family::Member(*collection), + FacetSelector::Literal(_) => Family::Literal, + _ => return None, + }; + FAMILIES.contains(&family).then_some(family) +} +pub(super) fn supported(facet: &FacetSelector) -> bool { + family(facet).is_some() +} + +// Fixed paths for the seven unordered collections and the exact enum literal set. +// They are selected by the typed facet; callers cannot supply arbitrary paths. +pub(super) fn path(value: &Value, family: Family) -> Option { + let prefixes = ["/body/Oa/Shared", "/body/Sa/Shared"]; + let tails: &[&str] = match family { + Family::Member(ReferenceCollection::FunctionInputItems) => { + &["Port/contract/FunctionInput/incoming_items"] + } + Family::Member(ReferenceCollection::FunctionOutputItems) => { + &["Port/contract/FunctionOutput/outgoing_items"] + } + Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"], + Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"], + Family::Literal => &["DataType/definition/Enumeration/literals"], + Family::Member(ReferenceCollection::ExchangeItems) => { + return ["Interaction", "FunctionalExchange", "BoundaryExchange"] + .iter() + .map(|kind| format!("/body/{kind}/items")) + .find(|p| value.pointer(p).is_some_and(Value::is_array)); + } + Family::Member(ReferenceCollection::TransitionTriggers) => { + return value + .pointer("/body/StateTransition/triggers") + .is_some_and(Value::is_array) + .then(|| "/body/StateTransition/triggers".into()); + } + Family::Member(ReferenceCollection::TransitionEffects) => { + return value + .pointer("/body/StateTransition/effects") + .is_some_and(Value::is_array) + .then(|| "/body/StateTransition/effects".into()); + } + _ => return None, + }; + prefixes + .iter() + .flat_map(|prefix| tails.iter().map(move |tail| format!("{prefix}/{tail}"))) + .find(|p| value.pointer(p).is_some_and(Value::is_array)) +} +fn key(facet: &FacetSelector, layer: Layer) -> Option { + match facet { + FacetSelector::Member { keys, .. } => if layer == Layer::Oa { keys.oa } else { keys.sa } + .map(|id| serde_json::to_value(id).expect("Id")), + FacetSelector::Literal(keys) => if layer == Layer::Oa { + keys.oa.as_ref() + } else { + keys.sa.as_ref() + } + .map(|v| Value::String(v.clone())), + _ => None, + } +} +pub(super) fn overlap(a: &FacetSelector, b: &FacetSelector) -> bool { + if family(a).is_none() || family(a) != family(b) { + return false; + } + [Layer::Oa, Layer::Sa] + .into_iter() + .any(|layer| key(a, layer).is_some() && key(a, layer) == key(b, layer)) +} +pub(super) fn project_presence( + value: &mut Value, + facet: &FacetSelector, + layer: Layer, + present: bool, +) -> Result<(), String> { + let family = family(facet).ok_or("This keyed family remains read-only.")?; + let key = key(facet, layer) + .ok_or("Choose the destination identity in Members before setting or taking presence.")?; + let path = + path(value, family).ok_or("This collection is not applicable to the destination kind.")?; + let list = value + .pointer_mut(&path) + .and_then(Value::as_array_mut) + .ok_or("Destination collection is unavailable.")?; + if present { + if !list.contains(&key) { + list.push(key); + } + } else { + list.retain(|v| v != &key); + } + Ok(()) +} + +impl State { + pub(super) fn keyed_value( + &self, + project: &Project, + trace: &Trace, + value: &mut Value, + facet: &FacetSelector, + decision: SemanticDecision, + ) -> Result<(), String> { + let present = match decision { + SemanticDecision::KeepTarget => return Ok(()), + SemanticDecision::TakeSource => { + // A missing source identity is a target-local row, not a removal. + if key(facet, self.direction.source_layer()).is_none() { + return Ok(()); + } + let fact = capture_facet(project, trace.oa, trace.sa, facet)?; + scalar::value(&fact, self.direction.source_layer()).and_then(|v| v.as_bool()).ok_or("Source collection is not applicable; choose an explicit final presence or Keep.")? + } + SemanticDecision::Candidate => *self + .presence + .get(facet) + .ok_or("Choose final membership presence.")?, + }; + project_presence(value, facet, self.direction.target_layer(), present) + } + + pub(super) fn member_families(&self, project: &Project) -> Vec { + let Some(trace) = self.effective_trace(project) else { + return vec![]; + }; + let values: Vec<_> = [trace.oa, trace.sa] + .into_iter() + .filter_map(|id| Draft::from_project(project, id).map(|d| d.value)) + .collect(); + FAMILIES + .into_iter() + .filter(|family| values.iter().any(|value| path(value, *family).is_some())) + .collect() + } + + pub(super) fn member_choices(&self, project: &Project, layer: Layer) -> Vec { + let Some(trace) = self.effective_trace(project) else { + return vec![]; + }; + let id = if layer == Layer::Oa { + trace.oa + } else { + trace.sa + }; + let Some(mut draft) = Draft::from_project(project, id) else { + return vec![]; + }; + if layer == self.direction.target_layer() { + draft.set_context(self.combined_value(project, true).ok()); + } + let selected = if layer == Layer::Oa { + &self.member_oa + } else { + &self.member_sa + }; + let Some(path) = path(&draft.context_value(), self.member_family) else { + return selected.iter().cloned().collect(); + }; + crate::forms::reference_choices( + &draft, + project, + &format!("{path}/0"), + selected.as_ref().map_or(Id::nil(), |c| c.id), + ) + .into_iter() + .filter(|c| !c.id.is_nil()) + .collect() + } + + pub(super) fn edit_pair( + &mut self, + project: &Project, + facet: FacetSelector, + ) -> Result<(), String> { + self.member_family = family(&facet).ok_or("This keyed family remains read-only.")?; + match facet { + FacetSelector::Member { keys, .. } => { + self.member_oa = keys.oa.map(|id| view::choice(project, id)); + self.member_sa = keys.sa.map(|id| view::choice(project, id)); + } + FacetSelector::Literal(keys) => { + self.literal_oa = keys.oa.is_some(); + self.literal_sa = keys.sa.is_some(); + self.literal = keys.oa.or(keys.sa).unwrap_or_default(); + } + _ => unreachable!(), + } + self.tab = Tab::Members; + Ok(()) + } + + pub(super) fn stage_member(&mut self, project: &Project) -> Result<(), String> { + if !FAMILIES.contains(&self.member_family) { + return Err("This keyed family remains read-only.".into()); + } + let facet = match self.member_family { + Family::Member(collection) => FacetSelector::Member { + collection, + keys: KeyPair { + oa: self.member_oa.as_ref().map(|c| c.id), + sa: self.member_sa.as_ref().map(|c| c.id), + }, + }, + Family::Literal => FacetSelector::Literal(KeyPair { + oa: self.literal_oa.then(|| self.literal.clone()), + sa: self.literal_sa.then(|| self.literal.clone()), + }), + }; + let trace = self.effective_trace(project).ok_or("Choose a trace.")?; + capture_facet(project, trace.oa, trace.sa, &facet)?; + if let FacetSelector::Member { + keys: + KeyPair { + oa: Some(oa), + sa: Some(sa), + }, + .. + } = &facet + { + self.references.insert(*oa, *sa); + } + self.staged.insert(facet.clone()); + self.selected = Some(facet); + self.tab = Tab::Facts; + Ok(()) + } +} diff --git a/crates/oa-desktop/src/semantic_review/keyed_tests.rs b/crates/oa-desktop/src/semantic_review/keyed_tests.rs new file mode 100644 index 00000000..9505696c --- /dev/null +++ b/crates/oa-desktop/src/semantic_review/keyed_tests.rs @@ -0,0 +1,782 @@ +use super::{tests::*, *}; +use crate::{app::App, document::Document}; + +fn errors(s: &State) -> String { + s.error + .iter() + .chain(s.plan.iter().flat_map(|p| &p.conflicts)) + .cloned() + .collect::>() + .join("; ") +} +fn put(p: &mut Project, owner: Id, facet: &FacetSelector, present: bool) { + let layer = p.layer_of(owner).unwrap(); + let mut value = Draft::from_project(p, owner).unwrap().value; + keyed::project_presence(&mut value, facet, layer, present).unwrap(); + if let Some(element) = p.elements.get_mut(&owner) { + *element = serde_json::from_value(value).unwrap(); + } else { + p.relations + .insert(owner, serde_json::from_value(value).unwrap()); + } +} +fn contains(p: &Project, owner: Id, facet: &FacetSelector, oa: Id, sa: Id) -> bool { + scalar::value( + &capture_facet(p, oa, sa, facet).unwrap(), + p.layer_of(owner).unwrap(), + ) + .unwrap() + .as_bool() + .unwrap() +} +fn history(p: &mut Project, trace: Id, selectors: &[FacetSelector]) { + let t = &p.traces[&trace]; + let baseline = TraceBaseline { + oa: p.properties(t.oa).unwrap(), + sa: p.properties(t.sa).unwrap(), + semantic: Some(SemanticBaseline { + facets: selectors + .iter() + .map(|s| capture_facet(p, t.oa, t.sa, s).unwrap()) + .collect(), + }), + }; + p.traces.get_mut(&trace).unwrap().baseline = Some(baseline); +} +fn literal(name: &str) -> FacetSelector { + FacetSelector::Literal(KeyPair { + oa: Some(name.into()), + sa: Some(name.into()), + }) +} +fn enum_pair() -> (Project, Id, Id, Id) { + let mut p = Project::empty(); + let mut ids = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let id = p.add_element(Kind::DataType, layer, "Enum", None).unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType { + definition: DataTypeDefinition::Enumeration { + literals: vec!["retained".into()], + }, + default: Some(DataValue::Enumeration("retained".into())), + unit: String::new(), + }; + ids.push(id); + } + let trace = add_trace(&mut p, ids[0], ids[1], "Literal review"); + (p, ids[0], ids[1], trace) +} +fn member_pair(collection: ReferenceCollection) -> (Project, Id, Id, Id, FacetSelector, [Id; 2]) { + let mut p = Project::empty(); + let mut owners = vec![]; + let mut keys = vec![]; + let mut locals = vec![]; + for layer in [Layer::Oa, Layer::Sa] { + let work = if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }; + let kind = match collection { + ReferenceCollection::TransitionTriggers => Kind::Event, + ReferenceCollection::TransitionEffects | ReferenceCollection::Constrained => work, + _ => Kind::ExchangeItem, + }; + let key = p.add_element(kind, layer, "Mapped member", None).unwrap(); + let local = p.add_element(kind, layer, "Local member", None).unwrap(); + let owner = match collection { + ReferenceCollection::SignalItems => { + let id = p.add_element(Kind::Event, layer, "Signal", None).unwrap(); + *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Event { + body: EventBody::Signal { items: vec![] }, + }; + id + } + ReferenceCollection::Constrained => p + .add_element(Kind::Constraint, layer, "Constraint", None) + .unwrap(), + ReferenceCollection::ExchangeItems => { + let a = p.add_element(work, layer, "From", None).unwrap(); + let b = p.add_element(work, layer, "To", None).unwrap(); + let id = p.relate( + if layer == Layer::Oa { + RelationKind::Interaction + } else { + RelationKind::FunctionalExchange + }, + "Exchange", + a, + b, + ); + if let RelationBody::FunctionalExchange { + source_port, + target_port, + .. + } = p.relations[&id].body + { + for port in [source_port, target_port] { + if let Some(SharedBody::Port { contract, .. }) = + p.elements.get_mut(&port).unwrap().shared_mut() + { + match contract { + PortContract::FunctionInput { incoming_items } => { + *incoming_items = vec![key, local] + } + PortContract::FunctionOutput { outgoing_items } => { + *outgoing_items = vec![key, local] + } + _ => unreachable!(), + } + } + } + } + id + } + ReferenceCollection::TransitionTriggers | ReferenceCollection::TransitionEffects => { + let machine = p + .add_element(Kind::StateMachine, layer, "Machine", None) + .unwrap(); + let region = p + .add_element(Kind::Region, layer, "Region", Some(machine)) + .unwrap(); + let state = p + .add_element(Kind::State, layer, "State", Some(region)) + .unwrap(); + p.relate(RelationKind::StateTransition, "Transition", state, state) + } + ReferenceCollection::FunctionInputItems | ReferenceCollection::FunctionOutputItems => { + let parent = p.add_element(work, layer, "Owner", None).unwrap(); + let id = Id::new_v4(); + let contract = if layer == Layer::Oa { + PortContract::Legacy + } else if collection == ReferenceCollection::FunctionInputItems { + PortContract::FunctionInput { + incoming_items: vec![], + } + } else { + PortContract::FunctionOutput { + outgoing_items: vec![], + } + }; + let mut port = ports::make_port(&p, id, parent, "Port", contract).unwrap(); + if layer == Layer::Oa + && let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() + { + *legacy = Some(LegacyPortContract { + direction: PortDirection::InOut, + interface: None, + }); + } + p.elements.insert(id, port); + id + } + _ => unreachable!(), + }; + owners.push(owner); + keys.push(key); + locals.push(local); + } + let trace = add_trace(&mut p, owners[0], owners[1], "Membership review"); + let facet = FacetSelector::Member { + collection, + keys: KeyPair { + oa: Some(keys[0]), + sa: Some(keys[1]), + }, + }; + ( + p, + owners[0], + owners[1], + trace, + facet, + [locals[0], locals[1]], + ) +} +fn stage(app: &mut App, facet: &FacetSelector) { + send(app, Event::EditPair(facet.clone())); + send(app, Event::StageMember); + assert_eq!( + state(app).selected.as_ref(), + Some(facet), + "{}", + errors(state(app)) + ); +} + +#[test] +fn unordered_members_keep_take_candidate_preserve_other_rows_both_directions() { + for collection in [ + ReferenceCollection::SignalItems, + ReferenceCollection::ExchangeItems, + ReferenceCollection::TransitionTriggers, + ReferenceCollection::TransitionEffects, + ReferenceCollection::Constrained, + ] { + for reverse in [false, true] { + for decision in [ + SemanticDecision::KeepTarget, + SemanticDecision::TakeSource, + SemanticDecision::Candidate, + ] { + let (mut p, oa, sa, trace, facet, locals) = member_pair(collection); + let (source, target, local) = if reverse { + (sa, oa, locals[0]) + } else { + (oa, sa, locals[1]) + }; + put(&mut p, source, &facet, true); + let local_facet = FacetSelector::Member { + collection, + keys: KeyPair { + oa: reverse.then_some(local), + sa: (!reverse).then_some(local), + }, + }; + put(&mut p, target, &local_facet, true); + let mut app = App::with_project(p); + let before = app.document.snapshot(); + start(&mut app, source); + stage(&mut app, &facet); + decide(&mut app, facet.clone(), decision); + if decision == SemanticDecision::Candidate { + send(&mut app, Event::Presence(facet.clone(), true)); + } + // A target-only Take is an explicit acknowledgment, never implicit removal. + decide(&mut app, local_facet.clone(), SemanticDecision::TakeSource); + assert!( + state(&app).can_apply(), + "{collection:?} reverse={reverse} {decision:?}: {}", + errors(state(&app)) + ); + let proposed = state(&app).proposed.clone().unwrap(); + assert_eq!(app.document.bundle(), &before.bundle); + apply(&mut app); + assert!(app.workflow.is_none(), "{}", app.status); + assert_eq!(app.project(), &proposed); + assert_eq!( + contains(app.project(), target, &facet, oa, sa), + decision != SemanticDecision::KeepTarget + ); + assert!(contains(app.project(), target, &local_facet, oa, sa)); + assert!(app.project().elements.contains_key(&local)); + assert_eq!( + app.project().traces[&trace] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets + .len(), + 2 + ); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + } + } + } +} + +#[test] +fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() { + for collection in [ + ReferenceCollection::FunctionInputItems, + ReferenceCollection::FunctionOutputItems, + ] { + let (p, oa, sa, _, facet, _) = member_pair(collection); + let FacetSelector::Member { keys, .. } = &facet else { + unreachable!() + }; + let mut app = App::with_project(p); + start(&mut app, oa); + assert_eq!( + state(&app).member_families(app.project()), + vec![keyed::Family::Member(collection)] + ); + send(&mut app, Event::EditPair(facet.clone())); + assert!( + state(&app) + .member_choices(app.project(), Layer::Sa) + .iter() + .any(|c| Some(c.id) == keys.sa) + ); + send(&mut app, Event::StageMember); + decide(&mut app, facet.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(facet.clone(), true)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + apply(&mut app); + assert!(contains(app.project(), sa, &facet, oa, sa)); + assert!(matches!( + app.project().elements[&oa].shared(), + Some(SharedBody::Port { + contract: PortContract::Legacy, + .. + }) + )); + start(&mut app, sa); + stage(&mut app, &facet); + decide(&mut app, facet.clone(), SemanticDecision::KeepTarget); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + decide(&mut app, facet.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(facet, true)); + assert!( + !state(&app).can_apply(), + "OA typed membership must remain inapplicable" + ); + } +} + +#[test] +fn exact_literal_new_rows_and_removals_commit_both_directions_without_default_loss() { + for reverse in [false, true] { + let (mut p, oa, sa, _) = enum_pair(); + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + let incoming = literal(" New value "); + put(&mut p, source, &incoming, true); + let removed = literal("old"); + put(&mut p, target, &removed, true); + let created = literal("new value"); + let mut app = App::with_project(p); + start(&mut app, source); + for (facet, decision) in [ + (&incoming, SemanticDecision::TakeSource), + (&removed, SemanticDecision::Candidate), + (&created, SemanticDecision::Candidate), + ] { + stage(&mut app, facet); + decide(&mut app, facet.clone(), decision); + if decision == SemanticDecision::Candidate { + send(&mut app, Event::Presence(facet.clone(), facet == &created)); + } + } + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + apply(&mut app); + assert!(contains(app.project(), target, &incoming, oa, sa)); + assert!(contains(app.project(), target, &created, oa, sa)); + assert!(!contains(app.project(), target, &removed, oa, sa)); + assert!( + matches!(app.project().elements[&target].shared(),Some(SharedBody::DataType { default: Some(DataValue::Enumeration(value)), .. }) if value=="retained") + ); + let reopened = Document::from_bundle( + app.document.bundle().clone(), + source_project::ProjectLimits::default(), + ) + .unwrap(); + assert_eq!(reopened.project(), app.project()); + } +} + +#[test] +fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directions() { + for reverse in [false, true] { + let (mut p, oa, sa, trace) = enum_pair(); + let reset = literal("retained"); + let kept = FacetSelector::Header(HeaderFacet::Name); + history(&mut p, trace, &[reset.clone(), kept.clone()]); + let previous = p.traces[&trace].baseline.clone().unwrap(); + let mut app = App::with_project(p); + let mut bundle = app.document.bundle().clone(); + bundle + .files + .get_mut(&bundle.entry) + .unwrap() + .insert_str(0, "// Exact review rationale survives reset.\n"); + app.document.apply_bundle(bundle).unwrap(); + let before = app.document.snapshot(); + let revision = app.document.revision(); + start(&mut app, if reverse { sa } else { oa }); + send(&mut app, Event::Reset(reset.clone(), true)); + assert!(state(&app).decisions.is_empty()); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + assert_eq!( + state(&app) + .facts + .iter() + .find(|f| f.selector == reset) + .unwrap() + .drift, + Drift::Unreviewed + ); + send(&mut app, Event::Reset(reset.clone(), false)); + assert!(!state(&app).can_apply()); + assert_eq!( + state(&app) + .facts + .iter() + .find(|f| f.selector == reset) + .unwrap() + .drift, + Drift::Unchanged + ); + send(&mut app, Event::Reset(reset, true)); + apply(&mut app); + assert_eq!(app.document.revision(), revision + 1); + assert_eq!(app.project().elements, before.decoded.project.elements); + let after = &app.project().traces[&trace].baseline.as_ref().unwrap(); + assert_eq!(after.oa, previous.oa); + assert_eq!(after.sa, previous.sa); + assert_eq!( + after + .semantic + .as_ref() + .unwrap() + .facets + .iter() + .map(FacetReview::selector) + .collect::>(), + vec![kept] + ); + assert!( + app.document.bundle().files[&app.document.bundle().entry] + .contains("// Exact review rationale survives reset.") + ); + let accepted = app.document.snapshot(); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before.bundle); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &accepted.bundle); + } +} + +#[test] +fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap_resolution() { + for reverse in [false, true] { + let (mut p, oa, sa, trace, paired, _) = member_pair(ReferenceCollection::SignalItems); + let FacetSelector::Member { collection, keys } = &paired else { + unreachable!() + }; + let (source, target) = if reverse { (sa, oa) } else { (oa, sa) }; + let previous = FacetSelector::Member { + collection: *collection, + keys: KeyPair { + oa: if reverse { None } else { keys.oa }, + sa: if reverse { keys.sa } else { None }, + }, + }; + put(&mut p, source, &paired, true); + history(&mut p, trace, std::slice::from_ref(&previous)); + let mut app = App::with_project(p); + start(&mut app, source); + decide(&mut app, previous.clone(), SemanticDecision::KeepTarget); + stage(&mut app, &paired); + decide(&mut app, paired.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(paired.clone(), true)); + assert!(!state(&app).can_apply()); + assert_eq!(state(&app).decisions.len(), 2); + send(&mut app, Event::Decide(previous.clone(), None)); + assert!( + !state(&app).can_apply(), + "Historic pair cannot silently migrate" + ); + send(&mut app, Event::Reset(previous.clone(), true)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + send(&mut app, Event::Reset(previous, false)); + assert!(!state(&app).can_apply()); + assert!(state(&app).presence[&paired]); + let old = state(&app).resets.clone(); + assert!(old.is_empty()); + let historic = app.project().traces[&trace] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets[0] + .selector(); + send(&mut app, Event::Reset(historic, true)); + apply(&mut app); + assert!(contains(app.project(), target, &paired, oa, sa)); + assert_eq!( + app.project().traces[&trace] + .baseline + .as_ref() + .unwrap() + .semantic + .as_ref() + .unwrap() + .facets[0] + .selector(), + paired + ); + } +} + +#[test] +fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_draft() { + for reverse in [false, true] { + let (mut p, oa, sa, trace) = enum_pair(); + let ctor = FacetSelector::Data(DataFacet::Constructor); + history(&mut p, trace, std::slice::from_ref(&ctor)); + let mut app = App::with_project(p); + start(&mut app, if reverse { sa } else { oa }); + let created = literal("separate"); + stage(&mut app, &created); + decide(&mut app, created.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(created.clone(), true)); + send(&mut app, Event::Reset(ctor.clone(), true)); + decide(&mut app, ctor.clone(), SemanticDecision::Candidate); + assert!(state(&app).can_acknowledge_constructor(app.project(), &ctor)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + let reviewed = &state(&app).plan.as_ref().unwrap().reviews[0]; + assert_eq!( + reviewed + .acknowledged + .iter() + .cloned() + .collect::>(), + BTreeSet::from([ctor.clone(), created.clone()]) + ); + assert!(reviewed.retired.is_empty()); + send(&mut app, Event::Reset(ctor.clone(), false)); + assert!(!state(&app).can_apply()); + assert!(state(&app).presence[&created]); + send(&mut app, Event::Reset(ctor, true)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + } +} + +#[test] +fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repairable() { + let (p, oa, _, _) = enum_pair(); + let mut app = App::with_project(p); + start(&mut app, oa); + let unit = FacetSelector::Data(DataFacet::Unit); + decide(&mut app, unit.clone(), SemanticDecision::Candidate); + send( + &mut app, + Event::Input( + "/body/Sa/Shared/DataType/unit".into(), + " untouched unit ".into(), + ), + ); + decide(&mut app, unit.clone(), SemanticDecision::KeepTarget); + let retained = literal("retained"); + decide(&mut app, retained.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(retained.clone(), false)); + assert!( + !state(&app).can_apply(), + "Existing default must prevent removal" + ); + assert!(!state(&app).presence[&retained]); + send(&mut app, Event::Presence(retained.clone(), true)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + assert_eq!( + state(&app).draft.as_ref().unwrap().inputs["/body/Sa/Shared/DataType/unit"].raw, + " untouched unit " + ); + decide(&mut app, unit, SemanticDecision::Candidate); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); +} + +#[test] +fn unsupported_independent_families_and_missing_identities_do_not_gain_edit_authority() { + let (p, oa, _, _) = enum_pair(); + let mut app = App::with_project(p); + start(&mut app, oa); + let unsupported = FacetSelector::Member { + collection: ReferenceCollection::Steps, + keys: KeyPair { + oa: Some(oa), + sa: None, + }, + }; + decide(&mut app, unsupported.clone(), SemanticDecision::Candidate); + assert!(!state(&app).decisions.contains_key(&unsupported)); + send(&mut app, Event::MemberFamily(keyed::Family::Literal)); + send(&mut app, Event::Literal("unpaired".into())); + send(&mut app, Event::LiteralSide(Layer::Sa, false)); + send(&mut app, Event::StageMember); + let facet = state(&app).selected.clone().unwrap(); + decide(&mut app, facet.clone(), SemanticDecision::Candidate); + assert!(!state(&app).can_apply()); + send(&mut app, Event::LiteralSide(Layer::Oa, false)); + send(&mut app, Event::StageMember); + assert!(state(&app).error.is_some()); + assert_eq!( + state(&app).decisions.get(&facet), + Some(&SemanticDecision::Candidate) + ); +} + +#[test] +fn deleted_member_source_observation_removes_only_mapped_membership_both_directions() { + for reverse in [false, true] { + let (mut p, oa, sa, trace, facet, locals) = member_pair(ReferenceCollection::SignalItems); + let FacetSelector::Member { keys, .. } = &facet else { + unreachable!() + }; + let (source, target, deleted, local) = if reverse { + (sa, oa, keys.sa.unwrap(), locals[0]) + } else { + (oa, sa, keys.oa.unwrap(), locals[1]) + }; + let target_only = FacetSelector::Member { + collection: ReferenceCollection::SignalItems, + keys: KeyPair { + oa: reverse.then_some(local), + sa: (!reverse).then_some(local), + }, + }; + put(&mut p, source, &facet, true); + put(&mut p, target, &facet, true); + put(&mut p, target, &target_only, true); + history(&mut p, trace, std::slice::from_ref(&facet)); + p = prepare_command(&p, &Command::Delete(deleted)) + .unwrap() + .project; + let mut app = App::with_project(p); + start(&mut app, source); + stage(&mut app, &facet); + decide(&mut app, facet.clone(), SemanticDecision::TakeSource); + // Force mixed final-record composition, as well as the core Take projection. + let name = FacetSelector::Header(HeaderFacet::Name); + decide(&mut app, name, SemanticDecision::Candidate); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + apply(&mut app); + assert!(!contains(app.project(), target, &facet, oa, sa)); + assert!(contains(app.project(), target, &target_only, oa, sa)); + let retained = if reverse { + keys.oa.unwrap() + } else { + keys.sa.unwrap() + }; + assert!(app.project().elements.contains_key(&retained)); + } +} + +#[test] +fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() { + let (mut p, oa, _, trace) = enum_pair(); + let reset = FacetSelector::Header(HeaderFacet::Name); + history(&mut p, trace, std::slice::from_ref(&reset)); + let mut app = App::with_project(p.clone()); + start(&mut app, oa); + send(&mut app, Event::Reset(reset.clone(), true)); + app.document + .execute(Command::Rename(oa, "Changed after preview".into())) + .unwrap(); + let before = app.document.snapshot(); + apply(&mut app); + assert!(state(&app).error.as_ref().unwrap().contains("stale")); + assert!(state(&app).resets.contains(&reset)); + assert_eq!(app.document.bundle(), &before.bundle); + 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(); + start(&mut app, oa); + send(&mut app, Event::Reset(reset.clone(), true)); + let added = literal(&" exact long literal ".repeat(100)); + stage(&mut app, &added); + decide(&mut app, added.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(added.clone(), true)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + let before = app.document.snapshot(); + apply(&mut app); + assert!(state(&app).error.is_some()); + assert!(state(&app).resets.contains(&reset)); + assert_eq!( + state(&app).decisions.get(&added), + Some(&SemanticDecision::Candidate) + ); + assert!(state(&app).presence[&added]); + assert_eq!(app.document.bundle(), &before.bundle); + assert!(!app.document.can_undo()); +} + +#[test] +fn member_identity_labels_and_new_tabs_preserve_exact_names_and_deleted_keys() { + let (mut p, oa, _, trace, facet, _) = member_pair(ReferenceCollection::SignalItems); + let FacetSelector::Member { collection, keys } = &facet else { + unreachable!() + }; + p.elements.get_mut(&keys.oa.unwrap()).unwrap().name = "Absent intentionally in name".into(); + let name = view::title(&p, &facet); + assert!(name.contains("Absent intentionally in name")); + let deleted1 = Id::new_v4(); + let deleted2 = Id::new_v4(); + let old1 = FacetSelector::Member { + collection: *collection, + keys: KeyPair { + oa: Some(deleted1), + sa: None, + }, + }; + let old2 = FacetSelector::Member { + collection: *collection, + keys: KeyPair { + oa: Some(deleted2), + sa: None, + }, + }; + history(&mut p, trace, &[old1.clone(), old2.clone()]); + assert!(view::title(&p, &old1).contains(&deleted1.to_string())); + assert_ne!(view::title(&p, &old1), view::title(&p, &old2)); + assert_ne!( + view::title(&p, &literal("Absent")), + view::title( + &p, + &FacetSelector::Literal(KeyPair { + oa: None, + sa: Some("Absent".into()) + }) + ) + ); + let mut app = App::with_project(p); + start(&mut app, oa); + for tab in [Tab::History, Tab::Members, Tab::Facts] { + send(&mut app, Event::Tab(tab)); + let _ = state(&app).view(app.project()); + } + send(&mut app, Event::Reset(old1.clone(), true)); + assert!(state(&app).can_apply(), "{}", errors(state(&app))); + assert!(!state(&app).facts.iter().any(|f| f.selector == old1)); + assert!(state(&app).facts.iter().any(|f| f.selector == old2)); +} + +#[test] +fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_presence() { + let (mut p, oa, _, trace) = enum_pair(); + let reset = FacetSelector::Header(HeaderFacet::Name); + history(&mut p, trace, std::slice::from_ref(&reset)); + let mut app = App::with_project(p.clone()); + start(&mut app, oa); + send(&mut app, Event::Reset(reset.clone(), true)); + let added = literal("staged draft"); + stage(&mut app, &added); + decide(&mut app, added.clone(), SemanticDecision::Candidate); + send(&mut app, Event::Presence(added.clone(), true)); + send(&mut app, Event::DropStaged(added.clone())); + assert!(state(&app).decisions.is_empty()); + assert!(!state(&app).staged.contains(&added)); + assert!(state(&app).presence[&added]); + assert!(state(&app).can_apply()); + stage(&mut app, &added); + decide(&mut app, added.clone(), SemanticDecision::Candidate); + assert!(state(&app).presence[&added]); + p.traces + .get_mut(&trace) + .unwrap() + .baseline + .as_mut() + .unwrap() + .semantic + .as_mut() + .unwrap() + .facets + .clear(); + let crate::workflow::Workflow::Semantic(s) = app.workflow.as_mut().unwrap() else { + unreachable!() + }; + s.refresh(&p); + assert!(!s.can_apply()); + s.tab = Tab::History; + let _ = s.view(&p); + s.update(&p, Event::Reset(reset, false)); + assert!(s.can_apply(), "{}", errors(s)); + assert_eq!(s.decisions.get(&added), Some(&SemanticDecision::Candidate)); +} diff --git a/crates/oa-desktop/src/semantic_review/view.rs b/crates/oa-desktop/src/semantic_review/view.rs index 4be8c507..71cd9f0b 100644 --- a/crates/oa-desktop/src/semantic_review/view.rs +++ b/crates/oa-desktop/src/semantic_review/view.rs @@ -71,27 +71,37 @@ pub(super) fn group(facet: &FacetSelector) -> &'static str { } } pub(super) fn title(project: &Project, facet: &FacetSelector) -> String { - let keys = |keys: &KeyPair| { + let keys = |keys: &KeyPair, missing: &str| { format!( "{} ↔ {}", keys.oa .map(|id| choice(project, id).label) - .unwrap_or_else(|| "Absent".into()), + .unwrap_or_else(|| missing.into()), keys.sa .map(|id| choice(project, id).label) - .unwrap_or_else(|| "Absent".into()) + .unwrap_or_else(|| missing.into()) ) }; match facet { FacetSelector::Member { collection, keys: pair, - } => format!("{} · {}", words(&format!("{collection:?}")), keys(pair)), + } => format!( + "{} · {}", + words(&format!("{collection:?}")), + keys(pair, "No identity") + ), 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") + pair.oa + .as_ref() + .map(|s| format!("{s:?}")) + .unwrap_or_else(|| "No identity".into()), + pair.sa + .as_ref() + .map(|s| format!("{s:?}")) + .unwrap_or_else(|| "No identity".into()) ), FacetSelector::Annotation(pair) => format!( "Message value · {} ↔ {}", @@ -104,11 +114,17 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String { .map(|key| annotation_title(project, key)) .unwrap_or_else(|| "Absent".into()) ), - 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::Binding(pair) => format!("Lifeline binding · {}", keys(pair, "Absent")), + FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair, "Absent")), + FacetSelector::Incident { keys: pair, end } => { + format!("Incident {end:?} · {}", keys(pair, "Absent")) + } FacetSelector::Artifact { keys: pair, facet } => { - format!("Artifact {} · {}", words(&format!("{facet:?}")), keys(pair)) + format!( + "Artifact {} · {}", + words(&format!("{facet:?}")), + keys(pair, "Absent") + ) } _ => { let json = serde_json::to_value(facet).expect("Typed selector"); @@ -199,6 +215,12 @@ fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> Stri let value = scalar::observation(fact, layer); if value.as_str() == Some("NotApplicable") { "Not applicable".into() + } else if keyed::supported(&fact.selector()) { + if value["Value"].as_bool() == Some(true) { + "Present".into() + } else { + "Absent".into() + } } else { formatted(project, &value["Value"], &scalar::references(fact)) } @@ -237,11 +259,13 @@ impl State { 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), + row![action("Properties", Event::Tab(Tab::Facts)), action("Members / literals", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), 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).wrap(), ].spacing(8); let body = match self.tab { Tab::Facts => self.facts_view(project), Tab::References => self.references_view(project), + Tab::Members => self.members_view(project), + Tab::History => self.history_view(project), Tab::Impacts => self.impacts_view(project), }; content = content.push(body); @@ -266,7 +290,7 @@ impl State { .iter() .filter(|f| f.drift == Drift::Unreviewed) .count(); - 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)); + content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Ordered, annotation, binding and attached-record decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED)); if let Some(trace) = trace { content = content.push(text(summary(project, trace)).size(10).color(style::MUTED)); } @@ -426,6 +450,38 @@ impl State { row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14) ] .spacing(10); + if previous.is_some() && history::resettable(&fact.selector) { + let facet = fact.selector.clone(); + detail = detail.push( + checkbox(self.resets.contains(&facet)) + .label("Reset this exact previous observation before review") + .text_size(11) + .on_toggle(move |v| event(Event::Reset(facet.clone(), v))), + ); + } + if keyed::supported(&fact.selector) { + detail = detail.push(text("Presence changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry.").size(11)) + .push(action("Choose / change exact row identities…", Event::EditPair(fact.selector.clone()))); + if let Some(baseline) = trace + .and_then(|t| t.baseline.as_ref()) + .and_then(|b| b.semantic.as_ref()) + { + for prior in &baseline.facets { + let prior = prior.selector(); + if prior != fact.selector && keyed::overlap(&prior, &fact.selector) { + detail = detail.push( + checkbox(self.resets.contains(&prior)) + .label(format!( + "Reset overlapping history: {}", + title(project, &prior) + )) + .text_size(11) + .on_toggle(move |v| event(Event::Reset(prior.clone(), v))), + ); + } + } + } + } if let Some(proposed) = &self.proposed && let Some(trace) = trace && let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector) @@ -501,6 +557,19 @@ impl State { "Leave unselected", Event::Decide(fact.selector.clone(), None), )); + if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) + && keyed::supported(&fact.selector) + { + let facet = fact.selector.clone(); + detail = detail.push( + checkbox(self.presence.get(&facet).copied().unwrap_or(false)) + .label( + "Include in destination collection (unchecked removes only this entry)", + ) + .text_size(12) + .on_toggle(move |v| event(Event::Presence(facet.clone(), v))), + ); + } if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) && let Some(draft) = self.candidate_draft(&fact.selector) && let Some(paths) = self @@ -534,17 +603,24 @@ impl State { } else { 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 self.staged.contains(&fact.selector) { + detail = detail.push(action( + "Remove staged address and its decision", + Event::DropStaged(fact.selector.clone()), + )); + } let overlaps: Vec<_> = self .decisions .keys() .filter(|other| { **other != fact.selector && (constructors::contains(&fact.selector, other) - || constructors::contains(other, &fact.selector)) + || constructors::contains(other, &fact.selector) + || keyed::overlap(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)); + detail = detail.push(text("Related decisions are selected. Paired rows cannot review the same OA or SA identity twice. Replacing groups require one combined decision; first equal-kind acknowledgments may coexist with descendants. Resolve overlaps explicitly; all drafts are retained.").size(11)); for other in overlaps { detail = detail.push(action( format!("Leave {} unselected", title(project, other)), @@ -557,6 +633,7 @@ impl State { )); } if (is_group + || keyed::supported(&fact.selector) || constructors::selectors().iter().any(|group| { self.decisions.contains_key(group) && constructors::contains(group, &fact.selector) })) @@ -595,10 +672,106 @@ impl State { detail.into() } + fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let families = self.member_families(project); + let available = families.contains(&self.member_family); + let mut content = column![ + text("Review an individual member or literal").size(16), + text("Choose an address, then review its presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12), + pick_list(families, Some(self.member_family), |v| event(Event::MemberFamily(v))).text_size(12).padding(7), + ].spacing(9); + if !available { + content = content.push(text("This collection is not available on the current counterparts. Choose an available collection above; existing staged addresses remain removable below.").size(12)); + } else if self.member_family == keyed::Family::Literal { + content = content.push(text("Literal identity is exact, including case and spaces. Both sides use the same string; a rename requires separate old/new rows.").size(11)) + .push(text_input("Exact literal", &self.literal).on_input(|v| event(Event::Literal(v))).size(12).padding(7)) + .push(row![checkbox(self.literal_oa).label("Operational identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Oa,v))), checkbox(self.literal_sa).label("System identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Sa,v)))].spacing(12)); + } else { + content = content.push(text("Identities may exist outside the collection. Choosing both explicitly sets their OA → SA correspondence. No identity means an unpaired side, distinct from an absent member.").size(11)) + .push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(Event::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", Event::MemberOa(None))].spacing(7)) + .push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(Event::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", Event::MemberSa(None))].spacing(7)); + } + content = content.push(button(text("Use these identities and review presence").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember)))) + .push(text("Changing a row address retains its earlier decisions and drafts. Reset conflicting historical observations and leave overlapping decisions unselected explicitly.").size(11).color(style::MUTED)); + if !self.staged.is_empty() { + content = content.push(text("Staged addresses in this draft").size(13)); + for facet in &self.staged { + content = content.push( + row![ + text(title(project, facet)).size(11).width(Fill), + action( + "Remove address / decision", + Event::DropStaged(facet.clone()) + ) + ] + .spacing(7), + ); + } + } + scrollable(content).height(305).into() + } + + fn history_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { + let mut content = column![text("Exact previous observations").size(16), text("Check only the observations to forget. Resets run before this review's decisions; reset alone changes history, not model values. Uncheck to restore the pending observation. Prose history remains separate.").size(12)].spacing(9); + let baseline = self + .trace + .and_then(|id| project.traces.get(&id)) + .and_then(|t| t.baseline.as_ref()) + .and_then(|b| b.semantic.as_ref()); + if baseline.is_none_or(|b| b.facets.is_empty()) { + content = + content.push(text("No previous semantic observations on this trace.").size(12)); + } + if let Some(baseline) = baseline { + for fact in &baseline.facets { + let facet = fact.selector(); + let mut item = column![ + text(title(project, &facet)).size(13), + text(format!( + "Operational: {}\nSystem: {}", + observed(project, Some(fact), Layer::Oa), + observed(project, Some(fact), Layer::Sa) + )) + .size(11) + ] + .spacing(4); + if history::resettable(&facet) { + item = item.push( + checkbox(self.resets.contains(&facet)) + .label("Reset this exact observation") + .text_size(11) + .on_toggle(move |v| event(Event::Reset(facet.clone(), v))), + ); + } else { + item = item.push( + text("Artifact history reset remains pending with artifact review.") + .size(11), + ); + } + content = content.push(container(item).padding(7)); + } + } + for pending in &self.resets { + if baseline.is_none_or(|b| !b.facets.iter().any(|f| f.selector() == *pending)) { + let facet = pending.clone(); + content = content.push( + checkbox(true) + .label(format!( + "Pending reset no longer in current history: {}", + title(project, &facet) + )) + .text_size(11) + .on_toggle(move |v| event(Event::Reset(facet.clone(), v))), + ); + } + } + scrollable(content).height(305).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("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires an exact reset in History. Existing decisions are retained; resolve their overlapping row identities explicitly.").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), diff --git a/harness/release-semantic-gui.md b/harness/release-semantic-gui.md index b839cf4c..1b8f2443 100644 --- a/harness/release-semantic-gui.md +++ b/harness/release-semantic-gui.md @@ -225,3 +225,67 @@ 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. + +## Independent unordered membership and exact reset slice + +Current scope: seven unordered reference collections (function input/output items, +signal/exchange items, transition triggers/effects, constrained identities) and exact +enumeration literals. Stage an explicit OA/SA address, then choose Keep, Take or +Candidate presence. New rows never create referenced model objects or infer pairs. +Pair changes preserve existing decisions and require explicit history resets and +overlap resolution. Literal pairing uses the same exact string on both sides. + +History lists exact previous observations, with reversible pending resets applied +before catalog construction, constructor scope and candidate composition. A reset-only +Apply uses the same core prepared command batch and atomic source/history undo. +Artifact history stays deferred; other read-only value families can have their +exact historical observations reset without exposing value editors. + +Independent annotation, binding, ordered/owned/incident/artifact decisions and +multi-source review remain later slices. Existing constructor forms remain intact. +The member-family picker offers collections available on the current counterparts. +Identity pickers reuse the ordinary property editor's typed choices. Presence and +all scalar/group drafts remain separate; leaving a decision or removing a staged +address retains its raw presence draft. A missing source key means a target-local +row that Take preserves; an explicit absent source key can remove only the mapped +entry. Existing model objects and unrelated entries remain intact. Previous member +identities, deleted UUIDs and quoted exact literals remain distinguishable. + +History and Members use the existing compact scroll area, and the contextual tabs +wrap. Users can explicitly remove staged addresses and their decisions or uncheck +obsolete pending resets after refresh, without discarding other work. Candidate +projection changes only the selected boolean member entries; the core independently +checks the complete typed candidate and supplies all acknowledged/retired/impact +scope. The GUI never synthesizes accepted history or additional-change commands. + +On base `c149f2f`, all **311 desktop tests pass**, including 12 new workflow tests. +The suite covers 30 five-family/direction/Keep-Take-Candidate cases, both typed +function-port item families with reverse Legacy applicability, exact literal +add/remove and default retention, historical pair migration with explicit overlap +resolution, constructor reset before equal-kind acknowledgment, deleted source +members and target-local preservation, exact reset-only source/Undo/Redo, source +reopen/comments, retained drafts, stale/source-write failures and recovery. +Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and +`cargo build --locked -p oa-desktop` all pass with the dedicated desktop target. +No core, API, schema or shared header changes were needed. + +Root-owned native acceptance uses the fixtures mirrored by `keyed_tests.rs`: +- Signal pair: source contains a mapped item; destination contains a separate local + item. In Members, choose Signal items and both mapped identities, then Take in + Properties. Inspect the proposed present row and retained local row; apply, undo, + redo and inspect source. Repeat in reverse. Test Candidate exclusion as well. +- Enum pair: both contain `retained` with that default; source also contains + ` New value ` and destination contains `old`. Stage exact paired literals; Take + the incoming row, Candidate-remove `old`, Candidate-add `new value`. Inspect the + distinct exact strings, unchanged default and one source/history transaction. +- Historical pair: an existing one-sided member observation is replaced by an + explicitly paired address. Apply stays unavailable until that exact old history + is reset and any overlapping earlier decision is explicitly left unselected. + Use History for a reset-only Apply; values and prose history stay unchanged, and + one Undo restores the exact prior source and semantic history. +- At 1100x700, verify wrapped tabs, scrollable member/history content, long/deleted + identity labels, retained errors/decisions, staged-address removal, source failure + repair and apply/cancel access; repeat a normal transaction at 1440x900. + +No native application was launched here. Independent review, actual native +acceptance, later keyed/ordered slices and full GUI parity remain open.