Compare commits

...
19 changed files with 5818 additions and 197 deletions
+38 -12
View File
@@ -1,5 +1,5 @@
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
use super::historical::keyed_slots_overlap;
use super::historical::{keyed_slots_overlap, selector_slots};
use super::mutate::*;
use super::*;
use crate::model::*;
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
}
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
match selector {
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
match layer {
Layer::Oa => &review.oa,
Layer::Sa => &review.sa,
}
FacetSelector::Literal(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
}
match fact {
FacetReview::Member { review, .. } | FacetReview::Literal { review, .. } => {
matches!(observed(review, layer), Observed::Value(true))
}
FacetSelector::Annotation(keys) => {
chosen(keys, source).is_none() && chosen(keys, target).is_some()
FacetReview::Annotation { review, .. } => {
matches!(observed(review, layer), Observed::Value(Some(_)))
}
FacetReview::Binding { review, .. } => {
matches!(observed(review, layer), Observed::Value(Some(_)))
}
_ => false,
}
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
source_layer: Layer,
target_layer: Layer,
) -> Result<(), String> {
// A correspondence key can exist without membership on that side (for
// example an OA literal automatically projects the same SA literal key).
// Preserve actual target-only values, except previously reviewed removals.
for fact in initial_facts {
if !constructor_contains(group, &fact.selector)
|| !target_only(&fact.selector, source_layer, target_layer)
|| member_present(&fact.current, source_layer)
|| !member_present(&fact.current, target_layer)
{
continue;
}
let slot = target_slot(target, &fact.selector, target_layer)?;
let was_corresponding = baseline.facets.iter().any(|old| {
!target_only(&old.selector(), source_layer, target_layer)
member_present(old, source_layer)
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
});
if was_corresponding {
@@ -723,12 +733,28 @@ fn build_review(
constructor_actions.push((target, selected.clone(), local_record));
}
let refreshed = semantic_facets(observed, &trace, &references)?;
let group_selectors: BTreeSet<_> = refreshed
let mut group_selectors: BTreeSet<_> = refreshed
.iter()
.chain(all.iter())
.map(|f| f.selector.clone())
.filter(|f| constructor_contains(selected, f))
.collect();
// A constructor copy can turn a live one-sided member into a
// pair. Its initial projection is not another reviewed fact.
// Established one-sided history still requires an explicit reset.
let paired_sides: BTreeSet<_> = group_selectors
.iter()
.map(selector_slots)
.filter(|slots| slots.len() == 2)
.flatten()
.collect();
group_selectors.retain(|selector| {
!paired_sides.contains(selector)
|| baseline
.facets
.iter()
.any(|old| old.selector() == *selector)
});
let mut group = vec![];
for selector in group_selectors {
match capture_facet(observed, oa, sa, &selector) {
+20 -1
View File
@@ -395,11 +395,30 @@ pub fn plan_transition(
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
let omitted = if request.direction == TransitionDirection::SaToOa {
let mut omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
// An explicit existing Legacy counterpart can be compared and reviewed
// without inventing an OA typed port. Only this port is retained: dependent
// SA implementation records and new counterpart requests stay omitted.
for mapping in &request.mappings {
if project
.elements
.get(&mapping.source)
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
&& mapping
.target
.and_then(|target| project.elements.get(&target))
.is_some_and(|e| {
e.layer() == Layer::Oa
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
})
{
omitted.remove(&mapping.source);
}
}
let mut reported_omissions = BTreeSet::new();
if selected.len() != request.sources.len() {
conflict(
+392
View File
@@ -1917,3 +1917,395 @@ fn retired_source_branch_retains_historical_member_pair_in_both_directions() {
);
}
}
#[test]
fn constructor_closure_preserves_target_only_literals_in_both_directions() {
for reverse in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
enumeration(&mut p, target, &["local"]);
let t = trace(&mut p, oa, sa);
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: facts.into_iter().map(|f| f.current).collect(),
});
enumeration(&mut p, source, &["incoming"]);
let mut r = request(
source,
target,
vec![(
FacetSelector::Data(DataFacet::Constructor),
SemanticDecision::TakeSource,
)],
);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
let q = applied(&p, r);
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals }, ..
}) if literals == &["incoming", "local"]),
"reverse={reverse}"
);
let base = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(base).unwrap();
let incoming = FacetSelector::Literal(KeyPair {
oa: Some("incoming".into()),
sa: Some("incoming".into()),
});
assert_eq!(
base.facets
.iter()
.filter(|f| f.selector() == incoming)
.count(),
1
);
let facts = semantic_facets(&q, &q.traces[&t], &BTreeMap::new()).unwrap();
assert!(
facts
.iter()
.filter(|f| matches!(f.selector, FacetSelector::Literal(_)))
.all(|f| f.drift == Drift::Unchanged)
);
}
}
#[test]
fn constructor_closure_pairs_signal_members_and_requires_history_reset_in_both_directions() {
for reverse in [false, true] {
for historical in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::Event, Layer::Oa, "oa");
let sa = add(&mut p, Kind::Event, Layer::Sa, "sa");
let oa_item = add(&mut p, Kind::ExchangeItem, Layer::Oa, "oa_item");
let sa_item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "sa_item");
let (source, target, source_item) = if reverse {
(sa, oa, sa_item)
} else {
(oa, sa, oa_item)
};
set(
&mut p,
source,
SharedBody::Event {
body: EventBody::Signal {
items: vec![source_item],
},
},
);
set(
&mut p,
target,
SharedBody::Event {
body: EventBody::Change {
expression: "old".into(),
},
},
);
let t = trace(&mut p, oa, sa);
let previous = FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: if reverse {
KeyPair {
oa: None,
sa: Some(sa_item),
}
} else {
KeyPair {
oa: Some(oa_item),
sa: None,
}
},
};
if historical {
let facts = semantic_facets(&p, &p.traces[&t], &BTreeMap::new()).unwrap();
let old = facts
.into_iter()
.find(|f| f.selector == previous)
.unwrap()
.current;
p.traces
.get_mut(&t)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline { facets: vec![old] });
}
let mut r = request(
source,
target,
vec![(
FacetSelector::Event(EventFacet::Constructor),
SemanticDecision::TakeSource,
)],
);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
r.references.insert(oa_item, sa_item);
if historical {
let invalid = plan_semantic_transition(&p, r.clone()).unwrap();
assert!(
!invalid.conflicts.is_empty(),
"Established one-sided history requires reset: reverse={reverse}"
);
r.reviews[0].reset.push(previous);
}
let q = applied(&p, r);
let target_item = if reverse { oa_item } else { sa_item };
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::Event { body: EventBody::Signal { items } }) if items == &[target_item])
);
let base = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(base).unwrap();
let members: Vec<_> = base
.facets
.iter()
.filter(|f| {
matches!(
f.selector(),
FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
..
}
)
})
.collect();
assert_eq!(
members.len(),
1,
"reverse={reverse}, historical={historical}"
);
assert_eq!(
members[0].selector(),
FacetSelector::Member {
collection: ReferenceCollection::SignalItems,
keys: KeyPair {
oa: Some(oa_item),
sa: Some(sa_item)
}
}
);
assert!(
validation::validate(&q)
.iter()
.all(|issue| issue.severity != validation::Severity::Error)
);
}
}
}
#[test]
fn constructor_closure_does_not_restore_previously_corresponding_removed_members() {
for reverse in [false, true] {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "oa");
let sa = add(&mut p, Kind::DataType, Layer::Sa, "sa");
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
enumeration(&mut p, source, &["paired"]);
let t = trace(&mut p, oa, sa);
let constructor = FacetSelector::Data(DataFacet::Constructor);
let directed = |facets| {
let mut r = request(source, target, facets);
if reverse {
r.transition.direction = TransitionDirection::SaToOa;
}
r
};
p = applied(
&p,
directed(vec![(constructor.clone(), SemanticDecision::KeepTarget)]),
);
if reverse {
// Explicitly establish the counterpart key while its target branch
// is absent, rather than silently changing the one-sided history.
let mut r = directed(vec![(
FacetSelector::Literal(KeyPair {
oa: Some("paired".into()),
sa: Some("paired".into()),
}),
SemanticDecision::KeepTarget,
)]);
r.reviews[0].reset.push(FacetSelector::Literal(KeyPair {
oa: None,
sa: Some("paired".into()),
}));
p = applied(&p, r);
}
enumeration(&mut p, source, &["incoming"]);
enumeration(&mut p, target, &["paired", "local"]);
let q = applied(
&p,
directed(vec![(constructor, SemanticDecision::TakeSource)]),
);
assert!(
matches!(q.elements[&target].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Enumeration { literals }, ..
}) if literals == &["incoming", "local"]),
"reverse={reverse}"
);
validate_semantic_baseline(
q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap(),
)
.unwrap();
}
}
fn reverse_legacy_port_fixture() -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let oa_owner = add(&mut p, Kind::Actor, Layer::Oa, "oa owner");
let sa_owner = add(&mut p, Kind::SystemActor, Layer::Sa, "sa owner");
let oa = Id::new_v4();
let sa = Id::new_v4();
let mut legacy = make_port(&p, oa, oa_owner, "oa port", PortContract::Legacy).unwrap();
*legacy.shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
};
p.elements.insert(oa, legacy);
let typed = make_port(
&p,
sa,
sa_owner,
"sa port",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
)
.unwrap();
p.elements.insert(sa, typed);
let t = trace(&mut p, oa, sa);
(p, oa, sa, t)
}
#[test]
fn reverse_existing_port_review_retains_legacy_and_roundtrips_typed_history() {
for decision in [SemanticDecision::KeepTarget, SemanticDecision::Candidate] {
let (p, oa, sa, t) = reverse_legacy_port_fixture();
let mut r = request(
sa,
oa,
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
);
r.transition.direction = TransitionDirection::SaToOa;
r.traces.insert(sa, t);
let expected_direction = if decision == SemanticDecision::Candidate {
let mut candidate = p.elements[&oa].clone();
let Some(SharedBody::Port {
legacy: Some(old), ..
}) = candidate.shared_mut()
else {
panic!()
};
old.direction = PortDirection::In;
r.candidates.push(SemanticRecord::Element(candidate));
PortDirection::In
} else {
PortDirection::InOut
};
let q = applied(&p, r);
assert_eq!(q.elements[&sa], p.elements[&sa]);
assert_eq!(q.elements.len(), p.elements.len());
assert!(matches!(q.elements[&oa].shared(), Some(SharedBody::Port {
contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, interface: None }),
}) if *direction == expected_direction));
let baseline = q.traces[&t]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap();
validate_semantic_baseline(baseline).unwrap();
assert!(baseline.facets.iter().any(|f| matches!(
f,
FacetReview::Port(PortReview::Constructor(Reviewed {
oa: Observed::Value(PortConstructor::Legacy),
sa: Observed::Value(PortConstructor::Component),
}))
)));
assert_eq!(language::parse(&language::format(&q)).unwrap(), q);
assert_eq!(
persistence::decode(&serde_json::to_vec(&q).unwrap()).unwrap(),
q
);
}
}
#[test]
fn reverse_existing_port_review_rejects_typed_oa_proposals_without_mutation() {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let (p, oa, sa, _) = reverse_legacy_port_fixture();
let snapshot = p.clone();
let mut r = request(
sa,
oa,
vec![(FacetSelector::Port(PortFacet::Constructor), decision)],
);
r.transition.direction = TransitionDirection::SaToOa;
if decision == SemanticDecision::Candidate {
let mut candidate = p.elements[&oa].clone();
*candidate.shared_mut().unwrap() = p.elements[&sa].shared().unwrap().clone();
r.candidates.push(SemanticRecord::Element(candidate));
}
let plan = plan_semantic_transition(&p, r).unwrap();
assert!(!plan.conflicts.is_empty());
assert!(
plan.conflicts
.iter()
.all(|c| !c.contains("outside selected transition")),
"Must reach structural validation: {:?}",
plan.conflicts
);
assert!(prepare_semantic_transition(&p, &plan).is_err());
assert_eq!(p, snapshot);
}
}
#[test]
fn reverse_new_port_counterpart_remains_an_explicit_sa_only_omission() {
let (p, _, sa, _) = reverse_legacy_port_fixture();
let mut r = TransitionRequest::new(TransitionDirection::SaToOa, vec![sa]);
r.mappings.push(TransitionMapping {
source: sa,
target: None,
target_kind: Some(Kind::Port),
});
let plan = plan_transition(&p, &r).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert!(plan.changes.is_empty());
assert!(plan.warnings.iter().any(|w| w.contains("SA-only omission")));
assert_eq!(apply_transition(&p, &plan).unwrap(), p);
}
+321 -33
View File
@@ -39,6 +39,9 @@ pub struct Draft {
baseline: Value,
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
pub semantic_groups: bool,
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
port_contract_drafts: BTreeMap<String, Value>,
}
@@ -76,6 +79,9 @@ impl Draft {
value,
inputs: BTreeMap::new(),
exact_strings: false,
context: None,
semantic_groups: false,
constructor_drafts: BTreeMap::new(),
port_contract_drafts: BTreeMap::new(),
};
draft.rebuild();
@@ -132,6 +138,16 @@ impl Draft {
pub fn materialize(&self) -> Result<Value, String> {
self.materialize_inputs(|_| true)
}
/// Read-only context for dependent controls. Workflow proposals never replace
/// the authored value or its input buffers and are not used by command().
pub fn context_value(&self) -> Value {
self.context
.clone()
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
}
pub fn set_context(&mut self, context: Option<Value>) {
self.context = context;
}
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
self.materialize_inputs(|path| {
@@ -216,6 +232,166 @@ impl Draft {
self.rebuild();
Ok(())
}
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
/// including an invalid value which this replacement deliberately removes.
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
*self
.value
.pointer_mut(path)
.ok_or("Property no longer exists.")? = value;
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
/// Preserve each selected constructor branch, including incomplete raw input
/// and editor state. No sibling fields are parsed or cleared by a switch.
pub fn variant_scoped(&mut self, path: &str, proposed: Value) -> Result<(), String> {
let current = self
.value
.pointer(path)
.ok_or("Property no longer exists.")?
.clone();
if same_variant(&current, &proposed) {
return Ok(());
}
let constructor = path.ends_with("/DataType/definition")
|| path.ends_with("/Port/contract")
|| path.ends_with("/Occurrence/body")
|| path.ends_with("/Event/body")
|| path.ends_with("/message_kind");
if !constructor {
return self.shape_scoped(path, proposed);
}
// Input/output direction changes carry the item declaration. Read those
// raw reference strings before stashing their editor buffers so an
// incomplete reference cannot silently fall back to its stored UUID.
let port_current = if path.ends_with("/Port/contract") {
self.materialize_scopes(&[path.to_owned()])?
.pointer(path)
.cloned()
} else {
None
};
let tag = |value: &Value| {
value
.as_str()
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
.unwrap_or("")
.to_owned()
};
let previous_key = (path.to_owned(), tag(&current));
let next_key = (path.to_owned(), tag(&proposed));
let mut inputs = BTreeMap::new();
for key in self
.inputs
.keys()
.filter(|key| within(key, path))
.cloned()
.collect::<Vec<_>>()
{
inputs.insert(key.clone(), self.inputs.remove(&key).unwrap());
}
self.constructor_drafts
.insert(previous_key, (current.clone(), inputs));
let saved = self.constructor_drafts.remove(&next_key);
let (value, restored) = if let Some(saved) = saved {
saved
} else {
let value = if path.ends_with("/Port/contract") {
port_fields::switch_contract(
port_current.as_ref().unwrap_or(&current),
proposed,
&mut BTreeMap::new(),
)
} else {
proposed
};
(value, BTreeMap::new())
};
self.shape_scoped(path, value)?;
self.inputs.extend(restored);
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
let previous = std::mem::take(&mut self.inputs);
self.rebuild();
for (path, input) in previous {
if let Some(path) = new_path(&path)
&& self.inputs.contains_key(&path)
{
self.inputs.insert(path, input);
}
}
}
pub fn array_scoped(
&mut self,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let mut candidate = self.value.clone();
let count = candidate
.pointer(path)
.and_then(Value::as_array)
.ok_or("List no longer exists.")?
.len();
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild_retaining(|old| {
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
return Some(old.to_owned());
};
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
let position = number.parse::<usize>().ok()?;
let position = match index {
Some(index) if direction == 0 && position == index => return None,
Some(index) if direction == 0 && position > index => position - 1,
Some(index) if direction != 0 => {
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
if position == index {
target
} else if position == target {
index
} else {
position
}
}
_ => position,
};
Some(if rest.is_empty() {
format!("{path}/{position}")
} else {
format!("{path}/{position}/{rest}")
})
});
Ok(())
}
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
self.value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.rebuild_retaining(|path| Some(path.to_owned()));
Ok(())
}
pub fn value_path_scoped(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let row = self
.value
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.rebuild_retaining(|old| {
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
.then(|| old.to_owned())
});
Ok(())
}
pub fn array(
&mut self,
path: &str,
@@ -223,36 +399,7 @@ impl Draft {
direction: i32,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let items = candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild();
Ok(())
@@ -354,6 +501,51 @@ impl Draft {
}
}
fn within(path: &str, root: &str) -> bool {
path == root
|| path
.strip_prefix(root)
.is_some_and(|tail| tail.starts_with('/'))
}
fn edit_array(
value: &mut Value,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let items = value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
Ok(())
}
fn friendly(name: &str) -> String {
match name {
"id" => "UUID identity".into(),
@@ -516,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<Choice> {
pub(crate) fn reference_choices(
draft: &Draft,
project: &Project,
path: &str,
current: Id,
) -> Vec<Choice> {
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
let compatible = reference_ids(draft, project, path);
let mut objects = compatible
@@ -568,7 +765,7 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
}
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let value = draft.context_value();
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
let owner = value
.get("element")
@@ -1156,6 +1353,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
"layer" => &["Oa", "Sa"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
@@ -1412,7 +1610,7 @@ fn property<'a>(
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
if path.ends_with("/Port/legacy") && !draft.semantic_groups {
let mut content = column![].spacing(4);
if !value.is_null() {
content = content.push(
@@ -1426,6 +1624,29 @@ fn property<'a>(
}
return content.into();
}
if path.ends_with("/Port/legacy") && draft.semantic_groups && !skip_variant {
let mut content = column![
label_view(),
text(
"Retained generic association is part of this group. Change or clear it explicitly."
)
.size(11)
]
.spacing(5);
if value.is_null() {
content = content.push(button(text("Add generic association").size(11)).on_press(
Message::FieldShape(path.into(), json!({"direction":"InOut","interface":null})),
));
} else {
content = content
.push(property(draft, project, value, path, label, true))
.push(
button(text("Clear generic association").size(11))
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
return content.into();
}
let enum_choices = if skip_variant {
vec![]
} else {
@@ -1683,6 +1904,73 @@ fn property<'a>(
mod tests {
use super::*;
#[test]
fn semantic_port_group_choices_use_combined_owner_and_retain_invalid_transferred_items() {
let mut p = Project::empty();
let function = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
let component = p
.add_element(Kind::System, Layer::Sa, "Component", None)
.unwrap();
let item = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Item", None)
.unwrap();
let id = Id::new_v4();
p.elements.insert(
id,
ports::make_port(
&p,
id,
function,
"Port",
PortContract::FunctionInput {
incoming_items: vec![item],
},
)
.unwrap(),
);
let mut draft = Draft::from_project(&p, id).unwrap();
draft.semantic_groups = true;
let path = "/body/Sa/Shared/Port/contract";
let input = format!("{path}/FunctionInput/incoming_items/0");
draft.input(&input, "incomplete-id".into());
let cursor = draft.inputs[&input].editor.cursor();
draft
.variant_scoped(path, json!({"FunctionOutput":{"outgoing_items":[]}}))
.unwrap();
assert_eq!(
draft.inputs[&format!("{path}/FunctionOutput/outgoing_items/0")].raw,
"incomplete-id"
);
assert!(serde_json::from_value::<Element>(draft.materialize().unwrap()).is_err());
draft
.variant_scoped(path, json!({"FunctionInput":{"incoming_items":[]}}))
.unwrap();
assert_eq!(draft.inputs[&input].raw, "incomplete-id");
assert_eq!(draft.inputs[&input].editor.cursor(), cursor);
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(
choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
assert!(choices.iter().any(|(_, v)| *v == json!("Legacy")));
assert!(!choices.iter().any(|(_, v)| v.get("Component").is_some()));
let mut context = draft.value.clone();
context["parent"] = json!(component);
draft.set_context(Some(context));
let choices =
port_fields::variants(&draft, &p, path, draft.value.pointer(path).unwrap()).unwrap();
assert!(choices.iter().any(|(_, v)| v.get("Component").is_some()));
assert!(
!choices
.iter()
.any(|(_, v)| v.get("FunctionInput").is_some())
);
}
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
reference_ids(draft, project, path)
}
+256 -5
View File
@@ -3,7 +3,7 @@ use super::*;
use iced::widget::column;
fn element(draft: &Draft, project: &Project) -> Option<Element> {
serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone()))
serde_json::from_value(draft.context_value())
.ok()
.or_else(|| project.elements.get(&draft.original).cloned())
}
@@ -113,9 +113,9 @@ pub(super) fn property<'a>(
let minimum_path = path.replace("maximum", "minimum");
json!(
draft
.materialize()
.ok()
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
.context_value()
.pointer(&minimum_path)
.and_then(Value::as_u64)
.unwrap_or(1)
.max(1)
)
@@ -311,7 +311,7 @@ fn message_value<'a>(
value: &'a Value,
path: &str,
) -> View<'a, Message> {
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let edited = draft.context_value();
let row = edited.pointer(path).unwrap_or(value).clone();
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
let owner = element(draft, project);
@@ -439,6 +439,257 @@ fn message_value<'a>(
mod tests {
use super::*;
fn semantic_state(app: &crate::app::App) -> &crate::semantic_review::State {
let Some(crate::workflow::Workflow::Semantic(state)) = &app.workflow else {
panic!()
};
state
}
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
use crate::semantic_review::Event;
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
semantic_send(
app,
Event::Decide(FacetSelector::Field(facet), Some(decision)),
);
}
fn default_buttons(draft: &Draft, project: &Project, path: &str) -> usize {
use iced::advanced::widget::{Tree, tree::Tag};
let button: View<'_, Message> = button(text("Button")).into();
let tag = Tree::new(button.as_widget()).tag;
let view = property(draft, project, draft.value.pointer(path).unwrap(), path).unwrap();
fn count(tree: &Tree, tag: Tag) -> usize {
usize::from(tree.tag == tag)
+ tree
.children
.iter()
.map(|child| count(child, tag))
.sum::<usize>()
}
count(&Tree::new(view.as_widget()), tag)
}
#[test]
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
use crate::semantic_review::{Event, State};
let mut p = Project::empty();
let oa = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
.unwrap();
let sa = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Target", None)
.unwrap();
let operation = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Operation base", None)
.unwrap();
let flow = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Flow base", None)
.unwrap();
for (id, next) in [
(oa, ExchangeMechanism::Operation),
(sa, ExchangeMechanism::Flow),
(operation, ExchangeMechanism::Operation),
(flow, ExchangeMechanism::Flow),
] {
let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&id).unwrap().shared_mut()
else {
panic!()
};
*mechanism = next;
}
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "item_trace".into(),
name: "Review item".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
let mut state = State::new(&p, oa).unwrap();
let facet = FacetSelector::ExchangeItem(ExchangeItemFacet::Mechanism);
let path = "/body/Sa/Shared/ExchangeItem/supertype";
let raw = state.draft.as_ref().unwrap().value.clone();
state.update(
&p,
Event::Decide(facet.clone(), Some(SemanticDecision::TakeSource)),
);
assert!(state.can_apply(), "{:?}", state.error);
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
assert!(choices.contains(&operation));
assert!(!choices.contains(&flow));
assert_eq!(state.draft.as_ref().unwrap().value, raw);
state.update(&p, Event::Decide(facet, Some(SemanticDecision::KeepTarget)));
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
assert!(!choices.contains(&operation));
assert!(choices.contains(&flow));
}
#[test]
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
use crate::semantic_review::Event;
for reverse in [false, true] {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let source = (layer == Layer::Oa) != reverse;
let record = p
.add_element(Kind::DataType, layer, "Record", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() =
SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let field = p
.add_element(Kind::DataField, layer, "Payload", Some(record))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() =
SharedBody::DataField {
type_ref: if source {
TypeRef::Boolean
} else {
TypeRef::Integer
},
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
pair.push(field);
}
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "payload_trace".into(),
name: "Payload review".into(),
description: String::new(),
oa: pair[0],
sa: pair[1],
kind: TraceKind::Manual,
baseline: None,
},
);
let (source, target, layer) = if reverse {
(pair[1], pair[0], "Oa")
} else {
(pair[0], pair[1], "Sa")
};
let root = format!("/body/{layer}/Shared/DataField");
let default = format!("{root}/default");
let type_path = format!("{root}/type_ref");
let minimum = format!("{root}/minimum");
let mut app = crate::app::App::with_project(p);
let before = app.document.snapshot();
let _ = app.update(Message::StartSemanticReview(source));
// An unfinished candidate must not make the context fall back to
// the old target type, or contaminate a later Keep decision.
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::Candidate);
semantic_send(&mut app, Event::Input(minimum.clone(), "-".into()));
semantic_decide(&mut app, FieldFacet::Type, decision);
if decision == SemanticDecision::Candidate {
semantic_send(
&mut app,
Event::Variant(type_path.clone(), json!("Boolean")),
);
}
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
assert!(!semantic_state(&app).can_apply());
let draft = semantic_state(&app).draft.as_ref().unwrap();
assert!(matches!(
element(draft, app.project()).unwrap().shared(),
Some(SharedBody::DataField {
type_ref: TypeRef::Boolean,
..
})
));
assert_eq!(draft.inputs[&minimum].raw, "-");
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::KeepTarget);
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
assert!(semantic_state(&app).can_apply());
let draft = semantic_state(&app).draft.as_ref().unwrap();
assert_eq!(default_buttons(draft, app.project(), &default), 2);
// These are the same element/definition/literal helpers used by
// the visible Set literal default and Add literal buttons.
let owner = element(draft, app.project()).unwrap();
let Some(SharedBody::DataField { type_ref, .. }) = owner.shared() else {
panic!()
};
let initial = literal(&definition(app.project(), type_ref).unwrap()).unwrap();
assert_eq!(initial, json!({"Boolean": false}));
if decision == SemanticDecision::TakeSource {
assert_eq!(draft.value.pointer(&type_path), Some(&json!("Integer")));
}
// Reverting the type changes the controls without changing its
// retained candidate. Storage constraints likewise read context.
semantic_decide(&mut app, FieldFacet::Type, SemanticDecision::KeepTarget);
assert!(matches!(
element(semantic_state(&app).draft.as_ref().unwrap(), app.project())
.unwrap()
.shared(),
Some(SharedBody::DataField {
type_ref: TypeRef::Integer,
..
})
));
semantic_decide(&mut app, FieldFacet::Type, decision);
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::Candidate);
semantic_send(
&mut app,
Event::Variant(format!("{root}/storage"), json!("Reference")),
);
assert_eq!(
default_buttons(
semantic_state(&app).draft.as_ref().unwrap(),
app.project(),
&default
),
0
);
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::KeepTarget);
assert_eq!(
default_buttons(
semantic_state(&app).draft.as_ref().unwrap(),
app.project(),
&default
),
2
);
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
semantic_send(&mut app, Event::Shape(default, json!([initial])));
assert!(
semantic_state(&app).can_apply(),
"{:?}",
semantic_state(&app).error
);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(
matches!(app.project().elements[&target].shared(), Some(SharedBody::DataField {
type_ref: TypeRef::Boolean, default: Some(values), minimum: 0, storage: FieldStorage::Value, ..
}) if values == &vec![DataValue::Boolean(false)])
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
}
#[test]
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
let (mut f, next) = crate::data_values::tests::recursive_payload();
+10 -3
View File
@@ -42,10 +42,14 @@ pub(super) fn variants(
_value: &Value,
) -> Option<Vec<(String, Value)>> {
if path.ends_with("/Port/contract") {
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let current = draft.context_value();
let sa = current.pointer("/body/Sa").is_some();
if !sa {
return Some(vec![]);
return Some(if draft.semantic_groups {
vec![("Legacy generic port".into(), json!("Legacy"))]
} else {
vec![]
});
}
let owner = current
.get("parent")
@@ -69,6 +73,9 @@ pub(super) fn variants(
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
));
}
if draft.semantic_groups {
choices.push(("Legacy generic port".into(), json!("Legacy")));
}
return Some(choices);
}
if path.ends_with("/Port/contract/Component/kind") {
@@ -80,7 +87,7 @@ pub(super) fn variants(
);
}
if path.ends_with("/Port/contract/Component/orientation") {
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let current = draft.context_value();
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
let orientations: &[&str] =
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
+334 -84
View File
@@ -8,16 +8,28 @@ use oa_core::{transitions::*, *};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
mod constructors;
mod history;
mod keyed;
mod message_kind;
mod scalar;
mod view;
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;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Facts,
References,
Members,
History,
Impacts,
}
@@ -43,6 +55,16 @@ pub enum Event {
RemoveReference(Id),
AcceptImpact(usize, bool),
Preview,
Reset(FacetSelector, bool),
Presence(FacetSelector, bool),
MemberFamily(keyed::Family),
MemberOa(Option<Choice>),
MemberSa(Option<Choice>),
Literal(String),
LiteralSide(Layer, bool),
EditPair(FacetSelector),
StageMember,
DropStaged(FacetSelector),
}
pub struct State {
@@ -53,7 +75,19 @@ pub struct State {
pub selected: Option<FacetSelector>,
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
pub references: BTreeMap<Id, Id>,
pub resets: BTreeSet<FacetSelector>,
pub staged: BTreeSet<FacetSelector>,
pub presence: BTreeMap<FacetSelector, bool>,
pub member_family: keyed::Family,
pub member_oa: Option<Choice>,
pub member_sa: Option<Choice>,
pub literal: String,
pub literal_oa: bool,
pub literal_sa: bool,
pub draft: Option<Draft>,
// Each closed constructor has an independent draft, including nested kinds.
// Scalar and sibling drafts survive decision and constructor changes.
pub group_drafts: BTreeMap<FacetSelector, Draft>,
pub plan: Option<SemanticTransitionPlan>,
pub proposed: Option<Project>,
pub error: Option<String>,
@@ -95,7 +129,17 @@ 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,
proposed: None,
error: None,
@@ -122,15 +166,41 @@ 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();
self.draft = self
.target(project)
.and_then(|id| Draft::from_project(project, id));
self.group_drafts.clear();
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
for facet in constructors::selectors() {
if constructors::available(project, trace, &facet)
&& let Some(mut draft) = self
.target(project)
.and_then(|id| Draft::from_project(project, id))
{
draft.semantic_groups = true;
self.group_drafts.insert(facet, draft);
}
}
}
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
self.references.insert(trace.oa, trace.sa);
}
self.member_family = self
.member_families(project)
.first()
.copied()
.unwrap_or(keyed::Family::Literal);
self.refresh(project);
}
@@ -168,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(())
}
@@ -184,22 +265,21 @@ impl State {
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::Shape(path, value) => {
self.edit(project, &path, |d| d.shape_scoped(&path, value))
}
Event::Variant(path, value) => {
self.edit(project, &path, |d| d.variant_scoped(&path, value))
}
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array(&path, index, direction))
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
}
Event::Append(path, value) => {
self.edit(project, &path, |d| d.append_scoped(&path, value))
}
Event::ValuePath(path, key) => {
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
}
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;
@@ -252,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);
@@ -275,15 +406,23 @@ impl State {
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
return Err("Choose Set final value before editing this property.".into());
}
let root = self
let roots = self
.target(project)
.and_then(|id| scalar::binding(project, id, facet))
.ok_or("Selected scalar property is unavailable.")?;
if path != root && !path.starts_with(&format!("{root}/")) {
return Err("Only the selected scalar property can change in this draft.".into());
.and_then(|id| constructors::roots(project, id, facet))
.ok_or("Selected property is unavailable.")?;
if !roots
.iter()
.any(|root| path == root || path.starts_with(&format!("{root}/")))
{
return Err("Only the selected review property can change in this draft.".into());
}
let draft = self.draft.as_mut().ok_or("Choose an existing target.")?;
// The scoped view sends only descendants of the selected scalar property.
let draft = if scalar::grouped(facet) {
self.group_drafts.get_mut(facet)
} else {
self.draft.as_mut()
}
.ok_or("Choose an existing target.")?;
// The scoped view sends only the selected property or closed group roots.
// Protected identity and top-level body/perspective controls are never exposed.
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
return Err("Semantic candidates retain identity, alias and perspective.".into());
@@ -292,17 +431,35 @@ 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()
&& !scalar::grouped(facet)
&& (!scalar::grouped(facet)
|| (self.group_drafts.contains_key(facet)
&& self
.trace
.and_then(|id| project.traces.get(&id))
.is_some_and(|trace| constructors::available(project, trace, facet))))
}
fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> {
if scalar::grouped(facet) {
self.group_drafts.get(facet)
} else {
self.draft.as_ref()
}
}
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 {
let Some(trace) = self.effective_trace(project) else {
return false;
};
if trace
@@ -313,13 +470,12 @@ impl State {
{
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)
capture_facet(project, trace.oa, trace.sa, facet)
.ok()
.is_some_and(|current| {
scalar::value(&current, Layer::Oa).is_some()
&& scalar::observation(&current, Layer::Oa)
== scalar::observation(&current, Layer::Sa)
})
}
@@ -352,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
@@ -360,53 +516,7 @@ impl State {
.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 final_value = self.combined_value(project, false)?;
let candidate = if project.elements.contains_key(&target) {
SemanticRecord::Element(
serde_json::from_value(final_value)
@@ -423,19 +533,156 @@ impl State {
Ok(request)
}
/// Compose selected values for both the candidate and dependent form
/// controls. Incomplete inputs retain their stored shape only for controls;
/// request construction always requires every selected input to parse.
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
let (source, target) = if self.direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut final_value = Draft::from_project(project, target)
.ok_or("Target has no editable record.")?
.value;
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);
let original = final_value.clone();
let mut group_candidates = vec![];
// Outer groups precede nested groups and leaves. The core rejects actual
// overlapping replacement scopes; this order also supports first-equal
// discriminator acknowledgment together with explicit descendant values.
let mut decisions: Vec<_> = self.decisions.iter().collect();
decisions.sort_by_key(|(facet, _)| match facet {
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
facet if scalar::grouped(facet) => 1,
_ => 2,
});
for (facet, decision) in decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
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 {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => {
let draft = self
.candidate_draft(facet)
.ok_or("Target draft is unavailable.")?;
match draft.materialize_scopes(&roots) {
Ok(value) => value,
Err(_) if form_context => draft.value.clone(),
Err(error) => return Err(error),
}
}
SemanticDecision::TakeSource if scalar::grouped(facet) => {
if self.can_acknowledge_constructor(project, facet) {
original.clone()
} else {
constructors::take_value(project, &trace, self.direction, facet, &map)?
}
}
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
let mut supplied = original.clone();
*supplied
.pointer_mut(&roots[0])
.ok_or("Source property is unavailable.")? = value;
supplied
}
};
for path in roots {
let value = supplied
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?;
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
group_candidates.push((path.clone(), value.clone()));
}
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
}
}
if !form_context {
for (path, authored) in group_candidates {
if !original
.pointer(&path)
.zip(final_value.pointer(&path))
.is_some_and(|(original, final_value)| {
constructors::authored_agrees(original, &authored, final_value)
})
{
return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into());
}
}
}
Ok(final_value)
}
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 {
let context = self.combined_value(project, true).ok();
if let Some(draft) = &mut self.draft {
draft.set_context(context.clone());
}
for draft in self.group_drafts.values_mut() {
draft.set_context(context.clone());
}
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(&current, 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;
@@ -475,8 +722,11 @@ impl State {
}
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
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());
@@ -490,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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,327 @@
//! Closed constructor payload bindings. The core owns decisions and review scope;
//! this adapter supplies complete target records for its Candidate agreement check.
use super::{scalar, *};
pub(super) fn selectors() -> [FacetSelector; 5] {
[
FacetSelector::Data(DataFacet::Constructor),
FacetSelector::Port(PortFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
FacetSelector::Occurrence(OccurrenceFacet::MessageKind),
FacetSelector::Event(EventFacet::Constructor),
]
}
pub(super) fn roots(project: &Project, target: Id, facet: &FacetSelector) -> Option<Vec<String>> {
let root = scalar::binding(project, target, facet)?;
let mut roots = vec![root.clone()];
match facet {
FacetSelector::Data(DataFacet::Constructor) => {
roots.push(format!("{}/default", root.strip_suffix("/definition")?));
}
FacetSelector::Port(PortFacet::Constructor) => {
roots.push(format!("{}/legacy", root.strip_suffix("/contract")?));
}
_ => {}
}
Some(roots)
}
pub(super) fn available(project: &Project, trace: &Trace, facet: &FacetSelector) -> bool {
roots(project, trace.oa, facet).is_some() && roots(project, trace.sa, facet).is_some()
}
/// Used for explanation and explicit overlap controls, never for acknowledgment.
pub(super) fn contains(group: &FacetSelector, facet: &FacetSelector) -> bool {
use FacetSelector as F;
match group {
F::Data(DataFacet::Constructor) => {
matches!(facet, F::Data(f) if *f != DataFacet::Unit) || matches!(facet, F::Literal(_))
}
F::Port(PortFacet::Constructor) => matches!(
facet,
F::Port(_)
| F::Member {
collection: ReferenceCollection::FunctionInputItems
| ReferenceCollection::FunctionOutputItems,
..
}
),
F::Occurrence(OccurrenceFacet::Constructor) => {
matches!(facet, F::Occurrence(f) if !matches!(f, OccurrenceFacet::Precondition | OccurrenceFacet::Postcondition))
|| matches!(
facet,
F::Annotation(_)
| F::Binding(_)
| F::Member {
collection: ReferenceCollection::Steps
| ReferenceCollection::FragmentBranches,
..
}
| F::Order(_)
)
}
F::Occurrence(OccurrenceFacet::MessageKind) => matches!(
facet,
F::Occurrence(OccurrenceFacet::MessageKind | OccurrenceFacet::TimerExpression)
),
F::Event(EventFacet::Constructor) => matches!(
facet,
F::Event(_)
| F::Member {
collection: ReferenceCollection::SignalItems,
..
}
),
_ => false,
}
}
pub(super) fn description(facet: &FacetSelector) -> &'static str {
match facet {
FacetSelector::Data(_) => {
"Definition, bounds or literal members, record supertype and default. Unit stays independent."
}
FacetSelector::Port(_) => {
"Contract, item members and retained generic association. Owner stays independent."
}
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => {
"Occurrence body, including references, message annotations, bindings and branch or step order. Pre/postconditions stay independent."
}
FacetSelector::Occurrence(_) => {
"Message kind and Timer Expression. Exchange references and annotations stay independent."
}
FacetSelector::Event(_) => {
"Event body, including signal items or its complete time/change expression."
}
_ => "",
}
}
fn chosen<T>(keys: &KeyPair<T>, layer: Layer) -> Option<&T> {
match layer {
Layer::Oa => keys.oa.as_ref(),
Layer::Sa => keys.sa.as_ref(),
}
}
// The source payload is mapped by the core's typed visitor, never by replacing
// UUID-looking strings. For a historical same-kind Take, retain only current
// target-present/source-absent rows with no prior source-present observation. The final core planner
// checks this assembled record against its own independent expected result.
pub(super) fn take_value(
project: &Project,
trace: &Trace,
direction: TransitionDirection,
facet: &FacetSelector,
map: &BTreeMap<Id, Id>,
) -> Result<Value, String> {
let (source, target) = if direction == TransitionDirection::OaToSa {
(trace.oa, trace.sa)
} else {
(trace.sa, trace.oa)
};
let mut element = project
.elements
.get(&source)
.cloned()
.ok_or("Missing constructor source.")?;
if !message_kind::handles(facet) {
element.parent = None;
let mut missing = BTreeSet::new();
oa_core::references::visit_element_refs(&mut element, |id| {
if let Some(mapped) = map.get(id) {
*id = *mapped;
} else {
missing.insert(*id);
}
});
if !missing.is_empty() {
return Err(format!(
"Missing explicit reference correspondence: {missing:?}"
));
}
}
let supplied = serde_json::to_value(element).map_err(|e| e.to_string())?;
let mut value = Draft::from_project(project, target)
.ok_or("Missing target draft.")?
.value;
for (from, to) in roots(project, source, facet)
.ok_or("Source group is unavailable.")?
.iter()
.zip(roots(project, target, facet).ok_or("Target group is unavailable.")?)
{
*value
.pointer_mut(&to)
.ok_or("Target group payload is unavailable.")? = supplied
.pointer(from)
.cloned()
.ok_or("Source group payload is unavailable.")?;
}
let current = capture_facet(project, trace.oa, trace.sa, facet)?;
if scalar::observation(&current, Layer::Oa) != scalar::observation(&current, Layer::Sa) {
return Ok(value);
}
let references = if direction == TransitionDirection::OaToSa {
map.clone()
} else {
map.iter().map(|(sa, oa)| (*oa, *sa)).collect()
};
let old = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref());
for fact in semantic_facets(project, trace, &references)? {
if !contains(facet, &fact.selector) {
continue;
}
let Some((path, key)) =
target_row(project, target, &fact.selector, direction.target_layer())
else {
continue;
};
if row_present(&fact.current, direction.source_layer())
|| !row_present(&fact.current, direction.target_layer())
{
continue;
}
let was_paired = old.is_some_and(|b| {
b.facets.iter().any(|previous| {
row_present(previous, direction.source_layer())
&& target_row(
project,
target,
&previous.selector(),
direction.target_layer(),
)
.as_ref()
== Some(&(path.clone(), key.clone()))
})
});
if was_paired {
continue;
}
let row = scalar::value(&fact.current, direction.target_layer())
.ok_or("Target row is inapplicable.")?;
let items = value
.pointer_mut(&path)
.and_then(Value::as_array_mut)
.ok_or("Target collection is unavailable.")?;
match &fact.selector {
FacetSelector::Member { .. } | FacetSelector::Literal(_) => {
if row == Value::Bool(true) && !items.contains(&key) {
items.push(key);
} else if row == Value::Bool(false) {
items.retain(|v| v != &key);
}
}
FacetSelector::Annotation(_) => {
items.retain(|v| {
serde_json::from_value::<MessageValue>(v.clone())
.ok()
.map(|v| serde_json::to_value(AnnotationKey::from(&v)).unwrap())
.as_ref()
!= Some(&key)
});
if !row.is_null() {
items.push(row);
}
}
FacetSelector::Binding(_) => {
items.retain(|v| v.get("referenced") != Some(&key));
if !row.is_null() {
items.push(row);
}
}
_ => unreachable!(),
}
}
Ok(value)
}
fn row_present(fact: &FacetReview, layer: Layer) -> bool {
match fact {
FacetReview::Member { .. } | FacetReview::Literal { .. } => {
scalar::value(fact, layer) == Some(Value::Bool(true))
}
FacetReview::Annotation { .. } | FacetReview::Binding { .. } => {
scalar::value(fact, layer).is_some_and(|value| !value.is_null())
}
_ => false,
}
}
fn row_key(facet: &FacetSelector, layer: Layer) -> Option<Value> {
match facet {
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
chosen(keys, layer).map(|v| serde_json::json!(v))
}
FacetSelector::Literal(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)),
FacetSelector::Annotation(keys) => chosen(keys, layer).map(|v| serde_json::json!(v)),
_ => None,
}
}
fn target_row(
project: &Project,
target: Id,
facet: &FacetSelector,
layer: Layer,
) -> Option<(String, Value)> {
let key = row_key(facet, layer)?;
let (group, suffix) = match facet {
FacetSelector::Literal(_) => (
FacetSelector::Data(DataFacet::Constructor),
"/Enumeration/literals",
),
FacetSelector::Binding(_) => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/ScenarioUse/bindings",
),
FacetSelector::Annotation(_) => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/Exchange/values",
),
FacetSelector::Member { collection, .. } => match collection {
ReferenceCollection::FunctionInputItems => (
FacetSelector::Port(PortFacet::Constructor),
"/FunctionInput/incoming_items",
),
ReferenceCollection::FunctionOutputItems => (
FacetSelector::Port(PortFacet::Constructor),
"/FunctionOutput/outgoing_items",
),
ReferenceCollection::SignalItems => (
FacetSelector::Event(EventFacet::Constructor),
"/Signal/items",
),
ReferenceCollection::Steps => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/Branch/occurrences",
),
ReferenceCollection::FragmentBranches => (
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
"/Fragment/branches",
),
_ => return None,
},
_ => return None,
};
Some((
format!("{}{suffix}", scalar::binding(project, target, &group)?),
key,
))
}
/// A selected descendant may fill untouched group fields during first-equal
/// acknowledgment, but cannot silently replace an authored group field.
pub(super) fn authored_agrees(original: &Value, authored: &Value, final_value: &Value) -> bool {
if original == authored || authored == final_value {
return true;
}
match (original, authored, final_value) {
(Value::Object(a), Value::Object(b), Value::Object(c)) if a.keys().eq(b.keys()) => {
b.iter().all(|(key, v)| {
c.get(key)
.is_some_and(|final_value| authored_agrees(&a[key], v, final_value))
})
}
_ => false,
}
}
@@ -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<Trace> {
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(())
}
}
@@ -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<Family> {
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<String> {
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<Value> {
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<Family> {
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<Choice> {
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(())
}
}
@@ -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::<Vec<_>>()
.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<_>>(),
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<_>>(),
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::<usize>() + 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));
}
@@ -0,0 +1,24 @@
//! The first supported constructor group is the closed nested MessageKind.
//! The core owns its acknowledgment/retirement and validation policies.
use super::*;
pub(super) fn selector() -> FacetSelector {
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
}
#[cfg(test)]
pub(super) fn timer_selector() -> FacetSelector {
FacetSelector::Occurrence(OccurrenceFacet::TimerExpression)
}
pub(super) fn handles(facet: &FacetSelector) -> bool {
*facet == selector()
}
#[cfg(test)]
pub(super) fn value(project: &Project, id: Id) -> Option<&MessageKind> {
match project.elements.get(&id)?.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} => Some(message_kind),
_ => None,
}
}
@@ -0,0 +1,717 @@
use super::{tests::*, *};
use crate::{app::App, document::Document};
use serde_json::json;
fn fixture(oa_kind: MessageKind, sa_kind: MessageKind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let mut ids = vec![];
for (layer, message_kind) in [(Layer::Oa, oa_kind), (Layer::Sa, sa_kind)] {
let scenario = p
.add_element(Kind::Scenario, layer, "Scenario", None)
.unwrap();
let id = p
.add_element(Kind::Occurrence, layer, "Message", Some(scenario))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind,
reply_to: None,
values: vec![],
},
precondition: "retain precondition".into(),
postcondition: "retain postcondition".into(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
ids.push(id);
}
let trace = add_trace(&mut p, ids[0], ids[1], "Message-kind review");
(p, ids[0], ids[1], trace)
}
fn timer(value: &str) -> MessageKind {
MessageKind::Timer {
expression: value.into(),
}
}
fn path(layer: &str) -> String {
format!("/body/{layer}/Shared/Occurrence/body/Exchange/message_kind")
}
fn set_kind(p: &mut Project, id: Id, kind: MessageKind) {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
}) = p.elements.get_mut(&id).unwrap().shared_mut()
else {
panic!()
};
*message_kind = kind;
}
fn seed_history(p: &mut Project, trace: Id) {
let t = &p.traces[&trace];
let baseline = TraceBaseline {
oa: p.properties(t.oa).unwrap(),
sa: p.properties(t.sa).unwrap(),
semantic: Some(SemanticBaseline {
facets: [message_kind::selector(), message_kind::timer_selector()]
.iter()
.map(|selector| capture_facet(p, t.oa, t.sa, selector).unwrap())
.collect(),
}),
};
p.traces.get_mut(&trace).unwrap().baseline = Some(baseline);
}
fn acknowledged(app: &App) -> BTreeSet<FacetSelector> {
state(app).plan.as_ref().unwrap().reviews[0]
.acknowledged
.iter()
.cloned()
.collect()
}
fn failures(app: &App) -> String {
state(app)
.error
.iter()
.chain(state(app).plan.iter().flat_map(|p| &p.conflicts))
.cloned()
.collect::<Vec<_>>()
.join("; ")
}
#[test]
fn message_group_take_full_timer_and_scalar_candidate_are_one_source_history_change_both_directions()
{
for reverse in [false, true] {
let expression = " after β ≥ 5 s\n\tkeep exact whitespace ";
let (p, oa, sa, trace) = if reverse {
fixture(MessageKind::Asynchronous, timer(expression))
} else {
fixture(timer(expression), MessageKind::Asynchronous)
};
let (source, target) = if reverse { (sa, oa) } else { (oa, sa) };
let mut app = App::with_project(p);
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Keep the engineering review rationale.\r\n");
app.document.apply_bundle(bundle).unwrap();
let before = app.document.snapshot();
let revision = app.document.revision();
start(&mut app, source);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::TakeSource,
);
decide(
&mut app,
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input("/name".into(), "Reviewed message".into()),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
acknowledged(&app),
BTreeSet::from([
message_kind::selector(),
message_kind::timer_selector(),
FacetSelector::Header(HeaderFacet::Name)
])
);
assert!(
state(&app).plan.as_ref().unwrap().reviews[0]
.retired
.is_empty()
);
assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty());
let expected = state(&app).proposed.clone().unwrap();
assert_eq!(
message_kind::value(&expected, target),
Some(&timer(expression))
);
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.revision(), revision + 1);
assert_eq!(
app.project().elements[&source],
before.decoded.project.elements[&source]
);
let saved = app.document.bundle().clone();
assert!(saved.files[&saved.entry].starts_with("// Keep the engineering review rationale."));
assert_eq!(
Document::from_bundle(saved.clone(), source_project::ProjectLimits::default())
.unwrap()
.snapshot()
.decoded
.project,
expected
);
let facts = &app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets;
assert_eq!(facts.len(), 3);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &saved);
}
}
#[test]
fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_explicit() {
let (p, oa, _, _) = fixture(MessageKind::Asynchronous, timer("original target"));
let mut app = App::with_project(p);
let before = app.document.snapshot();
let kind = path("Sa");
let expression = format!("{kind}/Timer/expression");
start(&mut app, oa);
decide(
&mut app,
message_kind::timer_selector(),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(expression.clone(), "independent scalar timer".into()),
);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
assert_eq!(state(&app).decisions.len(), 2);
assert!(!state(&app).can_apply());
send(
&mut app,
Event::Input(expression.clone(), " group β timer\n\t ".into()),
);
send(
&mut app,
Event::Text(expression.clone(), text_editor::Action::SelectAll),
);
let cursor = state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.cursor();
let selection = state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.selection();
send(
&mut app,
Event::Variant(kind.clone(), json!("Asynchronous")),
);
send(
&mut app,
Event::Variant(kind, json!({"Timer":{"expression":""}})),
);
assert_eq!(
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.raw,
" group β timer\n\t "
);
assert_eq!(
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.cursor(),
cursor
);
assert_eq!(
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.editor
.selection(),
selection
);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&expression].raw,
"independent scalar timer"
);
assert!(!state(&app).can_apply());
assert_eq!(app.document.bundle(), &before.bundle);
// The visible overlap-resolution actions only change the explicitly chosen
// decision. Neither independent candidate buffer is discarded.
send(&mut app, Event::Decide(message_kind::selector(), None));
assert!(state(&app).can_apply());
send(
&mut app,
Event::Decide(message_kind::timer_selector(), None),
);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
acknowledged(&app),
BTreeSet::from([message_kind::selector(), message_kind::timer_selector()])
);
let _ = state(&app).view(app.project());
let expected = state(&app).proposed.clone().unwrap();
apply(&mut app);
assert_eq!(app.project(), &expected);
}
#[test]
fn message_group_keep_historical_switch_retires_timer_and_completed_no_switch_rejects() {
let (mut p, oa, sa, trace) = fixture(timer("old OA"), timer("old SA"));
seed_history(&mut p, trace);
set_kind(&mut p, oa, MessageKind::Asynchronous);
set_kind(&mut p, sa, MessageKind::Asynchronous);
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::KeepTarget,
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
acknowledged(&app),
BTreeSet::from([message_kind::selector()])
);
assert_eq!(
state(&app).plan.as_ref().unwrap().reviews[0].retired,
vec![message_kind::timer_selector()]
);
apply(&mut app);
assert_eq!(
app.project().traces[&trace]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets
.len(),
1
);
start(&mut app, oa);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::KeepTarget,
);
assert!(!state(&app).can_apply());
assert!(
failures(&app).contains("No constructor switch"),
"{}",
failures(&app)
);
}
#[test]
fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_directions() {
for reverse in [false, true] {
let (mut p, oa, sa, _) = if reverse {
fixture(MessageKind::Asynchronous, MessageKind::Reply)
} else {
fixture(MessageKind::Reply, MessageKind::Asynchronous)
};
let (source, target, layer) = if reverse {
(sa, oa, "Oa")
} else {
(oa, sa, "Sa")
};
let mut calls = vec![];
for message in [oa, sa] {
let scenario = p.elements[&message].parent.unwrap();
let layer = p.elements[&message].layer();
let call = p
.add_element(Kind::Occurrence, layer, "Correlated call", Some(scenario))
.unwrap();
*p.elements.get_mut(&call).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Call,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.insert(0, call);
if message == source {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to, .. },
..
}) = p.elements.get_mut(&message).unwrap().shared_mut()
else {
panic!()
};
*reply_to = Some(call);
}
calls.push(call);
}
let target_call = if reverse { calls[0] } else { calls[1] };
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, source);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
send(&mut app, Event::Variant(path(layer), json!("Reply")));
decide(
&mut app,
FacetSelector::Occurrence(OccurrenceFacet::ReplyTo),
SemanticDecision::TakeSource,
);
assert!(!state(&app).can_apply());
assert!(
failures(&app).contains("reference counterparts"),
"{}",
failures(&app)
);
send(
&mut app,
Event::ReferenceOa(Choice {
id: calls[0],
label: "OA call".into(),
}),
);
send(
&mut app,
Event::ReferenceSa(Choice {
id: calls[1],
label: "SA call".into(),
}),
);
send(&mut app, Event::AddReference);
assert!(state(&app).can_apply(), "{}", failures(&app));
assert_eq!(
acknowledged(&app),
BTreeSet::from([
message_kind::selector(),
FacetSelector::Occurrence(OccurrenceFacet::ReplyTo)
])
);
let final_value =
serde_json::to_value(&state(&app).proposed.as_ref().unwrap().elements[&target])
.unwrap();
assert_eq!(
final_value.pointer(&format!(
"/body/{layer}/Shared/Occurrence/body/Exchange/reply_to"
)),
Some(&json!(target_call))
);
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert_eq!(
message_kind::value(app.project(), target),
Some(&MessageKind::Reply)
);
for call in calls {
assert_eq!(
app.project().elements[&call],
before.decoded.project.elements[&call]
);
}
}
}
#[test]
fn message_group_candidate_closed_kinds_are_core_validated_in_both_directions() {
for reverse in [false, true] {
for kind in [
MessageKind::Asynchronous,
MessageKind::Call,
MessageKind::Reply,
MessageKind::Create,
MessageKind::Delete,
timer("after 2 s"),
] {
let (p, oa, sa, _) = fixture(MessageKind::Asynchronous, MessageKind::Asynchronous);
let (source, target, layer) = if reverse {
(sa, oa, "Oa")
} else {
(oa, sa, "Sa")
};
let mut app = App::with_project(p);
start(&mut app, source);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Variant(path(layer), serde_json::to_value(&kind).unwrap()),
);
assert!(state(&app).can_apply(), "{kind:?}: {}", failures(&app));
assert_eq!(
message_kind::value(state(&app).proposed.as_ref().unwrap(), target),
Some(&kind)
);
assert_eq!(
acknowledged(&app).contains(&message_kind::timer_selector()),
matches!(kind, MessageKind::Timer { .. })
);
assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty());
}
}
let (mut p, oa, _, _) = fixture(MessageKind::Asynchronous, MessageKind::Asynchronous);
let mut review = State::new(&p, oa).unwrap();
let Some(SharedBody::Occurrence { body, .. }) = p.elements.get_mut(&oa).unwrap().shared_mut()
else {
panic!()
};
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
review.refresh(&p);
assert!(
!review.editable(&p, &message_kind::selector()),
"An externally changed outer occurrence requires its own constructor review"
);
review.update(
&p,
Event::Decide(message_kind::selector(), Some(SemanticDecision::Candidate)),
);
assert!(review.decisions.is_empty());
assert!(!review.can_apply());
}
#[test]
fn message_group_timer_does_not_erase_exchange_without_its_own_selected_decision() {
let (mut p, oa, sa, _) = fixture(MessageKind::Asynchronous, MessageKind::Asynchronous);
let sender = p
.add_element(Kind::Function, Layer::Sa, "Sender", None)
.unwrap();
let receiver = p
.add_element(Kind::Function, Layer::Sa, "Receiver", None)
.unwrap();
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Message exchange",
sender,
receiver,
);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: selected, ..
},
..
}) = p.elements.get_mut(&sa).unwrap().shared_mut()
else {
panic!()
};
*selected = Some(exchange);
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Variant(path("Sa"), json!({"Timer":{"expression":"after 2 s"}})),
);
assert!(!state(&app).can_apply());
assert!(
failures(&app).contains("timer_contract"),
"{}",
failures(&app)
);
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(app.document.bundle(), &before.bundle);
decide(
&mut app,
FacetSelector::Occurrence(OccurrenceFacet::Exchange),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Shape(
"/body/Sa/Shared/Occurrence/body/Exchange/exchange".into(),
Value::Null,
),
);
assert!(state(&app).can_apply(), "{}", failures(&app));
assert_eq!(
acknowledged(&app),
BTreeSet::from([
message_kind::selector(),
message_kind::timer_selector(),
FacetSelector::Occurrence(OccurrenceFacet::Exchange)
])
);
assert!(state(&app).plan.as_ref().unwrap().impacts.is_empty());
apply(&mut app);
assert_eq!(
app.project().relations[&exchange],
before.decoded.project.relations[&exchange]
);
}
#[test]
fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_timer_agreement() {
let (p, oa, sa, _) = fixture(timer("source timer"), timer("target timer"));
let mut app = App::with_project(p);
start(&mut app, oa);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::TakeSource,
);
decide(
&mut app,
FacetSelector::Header(HeaderFacet::Name),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input("/name".into(), "Final name".into()));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
message_kind::value(state(&app).proposed.as_ref().unwrap(), sa),
Some(&timer("target timer"))
);
assert!(!acknowledged(&app).contains(&message_kind::timer_selector()));
let expression = format!("{}/Timer/expression", path("Sa"));
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(expression.clone(), "authored group timer".into()),
);
assert!(
!state(&app).can_apply(),
"An unchanged initial kind cannot silently authorize a timer edit"
);
decide(
&mut app,
message_kind::timer_selector(),
SemanticDecision::Candidate,
);
send(
&mut app,
Event::Input(expression.clone(), "different scalar timer".into()),
);
assert!(!state(&app).can_apply());
assert!(
state(&app)
.error
.as_ref()
.unwrap()
.contains("different final values")
);
send(
&mut app,
Event::Input(expression, "authored group timer".into()),
);
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert!(acknowledged(&app).contains(&message_kind::timer_selector()));
assert_eq!(
message_kind::value(state(&app).proposed.as_ref().unwrap(), sa),
Some(&timer("authored group timer"))
);
}
#[test]
fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_losing_timer() {
for source_failure in [false, true] {
let (p, oa, _, _) = fixture(MessageKind::Asynchronous, timer("target"));
let mut app = App::with_project(p);
if source_failure {
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::<usize>() + 10;
app.document = Document::from_bundle(bundle, limits).unwrap();
}
start(&mut app, oa);
decide(
&mut app,
message_kind::selector(),
SemanticDecision::Candidate,
);
let expression = format!("{}/Timer/expression", path("Sa"));
let raw = "retain exact timer β\n".repeat(40);
send(&mut app, Event::Input(expression.clone(), raw.clone()));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
send(
&mut app,
Event::Input("/name".into(), "Unselected protected scope".into()),
);
assert!(!state(&app).can_apply());
assert!(
state(&app)
.error
.as_ref()
.unwrap()
.contains("selected review")
);
send(&mut app, Event::Preview);
assert!(state(&app).can_apply());
if !source_failure {
app.document
.execute(Command::Rename(oa, "External source edit".into()))
.unwrap();
}
let before = app.document.snapshot();
let revision = app.document.revision();
apply(&mut app);
assert!(state(&app).error.is_some());
assert_eq!(state(&app).decisions.len(), 1);
assert_eq!(
state(&app)
.group_drafts
.get(&message_kind::selector())
.unwrap()
.inputs[&expression]
.raw,
raw
);
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.document.revision(), revision);
}
}
+215 -8
View File
@@ -2,13 +2,13 @@ use super::*;
use crate::{app::App, document::Document, workflow::Workflow};
use serde_json::json;
fn send(app: &mut App, event: Event) {
pub(super) fn send(app: &mut App, event: Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn apply(app: &mut App) {
pub(super) fn apply(app: &mut App) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
}
fn state(app: &App) -> &State {
pub(super) fn state(app: &App) -> &State {
let Some(Workflow::Semantic(state)) = &app.workflow else {
panic!("Semantic workflow missing: {}", app.status)
};
@@ -35,7 +35,7 @@ fn fixture() -> (Project, Id, Id, Id) {
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 {
pub(super) fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
let id = Id::new_v4();
p.traces.insert(
id,
@@ -52,13 +52,217 @@ fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
);
id
}
fn start(app: &mut App, source: Id) {
pub(super) 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) {
#[test]
fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
let (p, oa, sa, _) = fixture();
let mut app = App::with_project(p);
let before = app.document.snapshot();
start(&mut app, oa);
let minimum = "/body/Sa/Shared/DataType/definition/Integer/minimum";
let maximum = "/body/Sa/Shared/DataType/definition/Integer/maximum";
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(minimum.into(), "-".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
facet(DataFacet::IntegerMaximum),
SemanticDecision::Candidate,
);
assert!(state(&app).can_apply());
// Exact event emitted by the upper bound's visible Clear bound button.
send(&mut app, Event::Shape(maximum.into(), Value::Null));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[minimum].raw, "-");
assert!(
state(&app)
.draft
.as_ref()
.unwrap()
.value
.pointer(maximum)
.unwrap()
.is_null()
);
// Clear must also discard the selected invalid value, without requiring it
// to parse before removal. A valid but uncommitted neighboring input survives.
send(&mut app, Event::Shape(maximum.into(), json!(30)));
send(&mut app, Event::Input(maximum.into(), "29".into()));
decide(
&mut app,
facet(DataFacet::IntegerMinimum),
SemanticDecision::Candidate,
);
assert!(!state(&app).can_apply());
send(&mut app, Event::Shape(minimum.into(), Value::Null));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[maximum].raw,
"29"
);
assert!(
!state(&app)
.draft
.as_ref()
.unwrap()
.inputs
.contains_key(minimum)
);
assert_eq!(app.document.bundle(), &before.bundle);
apply(&mut app);
assert!(matches!(
app.project().elements[&sa].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: None,
maximum: Some(29)
},
..
})
));
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
#[test]
fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers() {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let item = p
.add_element(Kind::ExchangeItem, layer, "Payload", None)
.unwrap();
let field = p
.add_element(Kind::DataField, layer, "Numbers", Some(item))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Integer,
minimum: 0,
maximum: None,
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: Some(vec![DataValue::Integer(1), DataValue::Integer(2)]),
};
pair.push(field);
}
add_trace(&mut p, pair[0], pair[1], "Numbers review");
let mut app = App::with_project(p);
start(&mut app, pair[0]);
let root = "/body/Sa/Shared/DataField";
let minimum = format!("{root}/minimum");
let default = format!("{root}/default");
let first = format!("{default}/0/Integer");
let second = format!("{default}/1/Integer");
decide(
&mut app,
FacetSelector::Field(FieldFacet::Minimum),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(minimum.clone(), "-".into()));
decide(
&mut app,
FacetSelector::Field(FieldFacet::Minimum),
SemanticDecision::KeepTarget,
);
decide(
&mut app,
FacetSelector::Field(FieldFacet::Default),
SemanticDecision::Candidate,
);
send(&mut app, Event::Input(first.clone(), "-".into()));
send(&mut app, Event::Input(second.clone(), "23".into()));
send(
&mut app,
Event::Append(default.clone(), json!({"Integer": 3})),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&second].raw,
"23"
);
// Same-variant reselection must not throw away the invalid authored value.
send(
&mut app,
Event::Variant(format!("{default}/0"), json!({"Integer": 0})),
);
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
send(&mut app, Event::Array(default.clone(), Some(0), 1));
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "23");
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&second].raw, "-");
assert!(!state(&app).can_apply());
send(&mut app, Event::Array(default, Some(1), 0));
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
assert_eq!(
state(&app).draft.as_ref().unwrap().inputs[&minimum].raw,
"-"
);
apply(&mut app);
assert!(
matches!(app.project().elements[&pair[1]].shared(), Some(SharedBody::DataField {
default: Some(values), minimum: 0, ..
}) if values == &vec![DataValue::Integer(23), DataValue::Integer(3)])
);
}
#[test]
fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity() {
let mut p = Project::empty();
let a = Id::new_v4();
let b = Id::new_v4();
let first = FacetSelector::Child(KeyPair {
oa: Some(a),
sa: None,
});
let second = FacetSelector::Child(KeyPair {
oa: Some(b),
sa: None,
});
let first_title = super::view::title(&p, &first);
let second_title = super::view::title(&p, &second);
assert_ne!(first_title, second_title);
assert!(first_title.contains(&a.to_string()));
assert!(second_title.contains(&b.to_string()));
let one = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
.unwrap();
let two = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Same name", None)
.unwrap();
let field = p
.add_element(Kind::DataField, Layer::Oa, "Value", Some(one))
.unwrap();
let annotation = |path| {
FacetSelector::Annotation(KeyPair {
oa: Some(AnnotationKey { path, field }),
sa: None,
})
};
let first_title = super::view::title(&p, &annotation(vec![one, a]));
let second_title = super::view::title(&p, &annotation(vec![two, b]));
assert_ne!(first_title, second_title);
for id in [one, field] {
assert!(first_title.contains(&format!("@{}", p.elements[&id].alias)));
}
assert!(first_title.contains(&a.to_string()));
assert!(second_title.contains(&b.to_string()));
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
}
pub(super) fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
send(app, Event::Select(selected.clone()));
send(app, Event::Decide(selected, Some(decision)));
}
@@ -182,7 +386,7 @@ fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
.error
.as_ref()
.unwrap()
.contains("selected scalar")
.contains("selected review")
);
assert_eq!(app.project(), &before.decoded.project);
send(&mut app, Event::Preview);
@@ -364,7 +568,10 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() {
send(
&mut app,
Event::Decide(
facet(DataFacet::Constructor),
FacetSelector::Literal(KeyPair {
oa: Some("Unpaired".into()),
sa: None,
}),
Some(SemanticDecision::TakeSource),
),
);
+340 -34
View File
@@ -71,40 +71,60 @@ pub(super) fn group(facet: &FacetSelector) -> &'static str {
}
}
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
let keys = |keys: &KeyPair<Id>| {
let keys = |keys: &KeyPair<Id>, missing: &str| {
format!(
"{} ↔ {}",
keys.oa.map(|id| project.named(id)).unwrap_or("Absent"),
keys.sa.map(|id| project.named(id)).unwrap_or("Absent")
keys.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| missing.into()),
keys.sa
.map(|id| choice(project, id).label)
.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 · {} ↔ {}",
pair.oa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent"),
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into()),
pair.sa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent")
.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");
@@ -118,6 +138,14 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
}
}
}
fn annotation_title(project: &Project, key: &AnnotationKey) -> String {
key.path
.iter()
.chain(std::iter::once(&key.field))
.map(|id| choice(project, *id).label)
.collect::<Vec<_>>()
.join(" / ")
}
fn drift(drift: Drift) -> &'static str {
match drift {
Drift::Unreviewed => "Unreviewed",
@@ -187,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))
}
@@ -225,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);
@@ -254,7 +290,7 @@ impl State {
.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));
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));
}
@@ -352,7 +388,43 @@ impl State {
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
let is_group = scalar::grouped(&fact.selector);
let group_observations = |project: &Project, facts: Vec<&FacetReview>, layer| {
facts
.into_iter()
.filter(|f| constructors::contains(&fact.selector, &f.selector()))
.map(|f| {
format!(
"{}: {}",
title(project, &f.selector()),
observed(project, Some(f), layer)
)
})
.collect::<Vec<_>>()
.join("\n")
};
let side = |layer, label: &str| {
let mut before = observed(project, previous, layer);
let mut current = observed(project, Some(&fact.current), layer);
if is_group {
before = group_observations(
project,
trace
.and_then(|t| t.baseline.as_ref())
.and_then(|b| b.semantic.as_ref())
.map(|b| b.facets.iter().collect())
.unwrap_or_default(),
layer,
);
if before.is_empty() {
before = "No previous group observations".into();
}
current = group_observations(
project,
self.facts.iter().map(|f| &f.current).collect(),
layer,
);
}
column![
text(format!(
"{label}{}",
@@ -365,9 +437,9 @@ impl State {
.size(12)
.color(style::TEAL),
text("Previously reviewed").size(10).color(style::MUTED),
text(observed(project, previous, layer)).size(12),
text(before).size(12),
text("Current").size(10).color(style::MUTED),
text(observed(project, Some(&fact.current), layer)).size(12),
text(current).size(12),
]
.spacing(5)
.width(Fill)
@@ -378,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)
@@ -389,11 +493,20 @@ impl State {
.color(style::TEAL),
)
.push(
text(observed(
proposed,
Some(&after),
self.direction.target_layer(),
))
text(if is_group {
semantic_facets(proposed, &proposed.traces[&trace.id], &self.references)
.ok()
.map(|facts| {
group_observations(
proposed,
facts.iter().map(|f| &f.current).collect(),
self.direction.target_layer(),
)
})
.unwrap_or_else(|| "Group payload is unavailable.".into())
} else {
observed(proposed, Some(&after), self.direction.target_layer())
})
.size(12),
);
} else {
@@ -404,6 +517,13 @@ impl State {
);
}
if self.editable(project, &fact.selector) {
if is_group {
detail = detail.push(text(constructors::description(&fact.selector)).size(12));
detail = detail.push(text("Replacing a kind reviews its complete payload. Review every removed reference, row and prior observation in the exact scope below; changing kind retains your draft for switching back.").size(11));
if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("These initial kinds are equal. Keep or Take acknowledges only the kind; an unchanged Candidate kind needs independent descendant decisions for payload edits.").size(11).color(style::MUTED));
}
}
let destination = if self.direction.target_layer() == Layer::Sa {
"System"
} else {
@@ -426,30 +546,120 @@ impl State {
]
.spacing(5);
buttons = buttons.push(action(
"Set final value…",
if is_group {
"Set final kind and payload…"
} else {
"Set final value…"
},
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
));
detail = detail.push(buttons).push(action(
detail = detail.push(buttons.wrap()).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))
&& keyed::supported(&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),
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
.target(project)
.and_then(|id| constructors::roots(project, id, &fact.selector))
{
detail = detail.push(text("Final destination value").size(12));
for path in paths {
let label = if path.ends_with("/default") {
"Default"
} else if path.ends_with("/legacy") {
"Generic association"
} else {
"Kind and payload"
};
detail = detail.push(
crate::forms::scoped(
draft,
project,
&path,
if is_group { label } else { "Final value" },
)
.map(form_event),
);
}
}
} else if self.can_acknowledge_constructor(project, &fact.selector) {
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
} else {
detail = detail.push(text(if scalar::grouped(&fact.selector) { "Constructor groups are shown for comparison. Group replacement and its complete descendant review are a later milestone." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
}
if 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)
|| keyed::overlap(other, &fact.selector))
})
.collect();
if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() {
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)),
Event::Decide(other.clone(), None),
));
}
detail = detail.push(action(
"Leave this decision unselected; use the related decisions",
Event::Decide(fact.selector.clone(), None),
));
}
if (is_group
|| keyed::supported(&fact.selector)
|| constructors::selectors().iter().any(|group| {
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
}))
&& let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first())
{
detail = detail.push(text("Exact review scope from preview").size(13));
for (label, selectors) in [
("Acknowledge", &review.acknowledged),
("Retire previous observation", &review.retired),
] {
detail = detail.push(
text(format!(
"{label}: {}",
if selectors.is_empty() {
"None".into()
} else {
selectors
.iter()
.map(|s| title(project, s))
.collect::<Vec<_>>()
.join(", ")
}
))
.size(11),
);
}
}
if matches!(
fact.selector,
@@ -462,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),
+1
View File
@@ -744,6 +744,7 @@ fn traces(app: &App) -> View<'_, Message> {
action("+ Artifact", Message::AddArtifact)
]
.spacing(8)
.wrap()
]
.spacing(16);
for t in p.traces.values() {
@@ -0,0 +1,47 @@
# Constructor history correction
Scope: `oa-core` only, based on HOST `f6a59de85b815fbea5ec578bb79d0a15819c42b4`. No desktop, source grammar, API shape, or OA port semantics changed by the keyed closure correction.
## Reproduction and decision
The original independent Boolean-to-Enumeration probe passes OA to SA on an unmodified HOST core. Extending the same fixture to SA to OA reproduces the duplicate `Literal(incoming,incoming)` conflict. New production-core regressions reproduce both the literal and `Event.Signal` member failures before the implementation change; `constructor-closure-red.log` records two failures. This is directional catalog projection, not UUID ordering: a live SA-only selector survives alongside the newly paired selector after a constructor copy.
Constructor closure now removes that redundant transient one-sided projection when a pair covers it. Established historical one-sided selectors remain, so changing their correspondence still requires an explicit reset. Both-sided historical pairs and removed branch observations retain their existing rules.
Suppressing the duplicate exposed another reverse-direction defect: target-local literals were lost because their implicit canonical key looked paired even when the source had no member. Preservation now uses observed membership (`true` or an optional row containing a value), with the existing prior source-membership guard preventing resurrection of reviewed removals. This implements the existing language.rst requirement to preserve target-only members when a historical switch reaches the target's current constructor; no normative contract was relaxed.
The intermediate normalization-only run (`constructor-closure-normalized.log`) passes Signal pairing but fails reverse literal preservation. Three final regressions cover both directions, no-history paired Signal insertion, established one-sided history requiring reset, local literal retention, and previously corresponding removed members staying removed. A separate read-only review checked selector normalization and presence semantics; its deletion-guard test recommendation was implemented.
## Verification
Dedicated target: `D:/CodexBuilds/Capella2-scenario-target`.
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-core --test r3_semantic
cargo test --locked --offline -p oa-core
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
cargo fmt --all -- --check
cargo run --offline --manifest-path C:/Users/sorok/AppData/Local/Temp/capella2-constructor-literal-host-probe/Cargo.toml
```
All 31 semantic tests and all 356 Windows core tests pass. Strict core all-target Clippy and workspace formatting pass. The independently adapted probe passes both directions against the final implementation (`constructor-independent-green.log`). Full logs: `constructor-core-test.log`, `constructor-core-clippy.log`, `constructor-r3-semantic-green.log` in the dedicated target. Actual GUI/API acceptance and Linux integration remain with the root engineer; this core-only milestone makes no native acceptance claim.
## Separate follow-up: review an existing OA Legacy port
The shared transition planner previously removed every typed SA port before resolving explicit existing-target mappings. Consequently an existing trace could not Keep or supply a Legacy Candidate in SA-to-OA semantic review: it failed with `Review source is outside selected transition mappings`. Two focused regressions reproduced that boundary on the committed keyed correction (`existing-port-red.log`).
The language contract permits same-kind Port traces and reviewed updates from either layer, while retaining OA Legacy-only compatibility. The planner now retains only explicitly mapped SA ports whose selected existing OA target is Legacy. It does not synthesize a target, change a port contract, or un-omit dependent SA implementation records. Structural validation still rejects typed OA Take/Candidate proposals. Automatic/new-counterpart omissions remain unchanged.
Three regressions exercise Keep and Candidate retaining Legacy, unchanged SA source, typed history source/JSON round trips, typed OA proposals failing without mutation, and an explicit new Port counterpart remaining an SA-only omission. A separate read-only review found no scope/invariant defect. Focused red-to-green result: all three pass (`existing-port-green.log`). This change uses the common planner consumed by desktop and REST; it adds no endpoint or transport-specific behavior.
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-scenario-target'
cargo test --locked --offline -p oa-core --test r3_semantic reverse_ -- --nocapture
cargo test --locked --offline -p oa-core
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
cargo fmt --all -- --check
```
All 359 Windows core tests pass, including all 34 semantic tests and existing portless-OA transition coverage. Strict core all-target Clippy and workspace formatting pass. Logs: `existing-port-core-test.log` and `existing-port-core-clippy.log` in the dedicated target. REST preview uses the corrected core planner at `oa-server/src/routes.rs` and commit uses the shared core prepare path; those handlers were inspected, not changed. GUI constructor tests and actual API/native acceptance remain with root integration; no native input or desktop code was changed here.
+226 -17
View File
@@ -1,7 +1,8 @@
# Semantic desktop review — milestones 1 and 2
# Semantic desktop review — scalars and closed constructor groups
Status: milestones 1–2 implemented and desktop tests/build verified; not full GUI
parity, native acceptance or release acceptance.
Status: milestones 1–2 and the closed constructor group forms are implemented.
Independent keyed/ordered review, historical pairing reset, multi-source review,
full GUI parity, native acceptance and release acceptance remain outstanding.
Base: `d130013`. Dedicated build target:
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
@@ -16,9 +17,10 @@ Base: `d130013`. Dedicated build target:
- Support every applicable scalar leaf through Keep, Take and typed final-value
forms. Candidate records contain the complete combined selected result. Identity,
alias and layer are protected. The closed core remains the validation boundary.
- State.Kind is a single structural scalar. Other constructor switches, keyed
membership/order/annotation/binding/attachment decisions and pairing resets belong
to milestone 3; show them read-only with an honest explanation.
- State.Kind is a single structural scalar. Data, Port, Occurrence, nested
MessageKind and Event support their closed constructor groups and complete
typed payloads. Independent keyed membership/order/annotation/binding/attachment
decisions and pairing resets remain visibly read-only outside a replacing group.
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
previously reviewed constructors remain governed by the core's switch rules.
@@ -36,13 +38,16 @@ Base: `d130013`. Dedicated build target:
2. Scalar decisions and typed candidates; immutable initial observations; combined
final records; exact impact acceptance; atomic document/source commit. Focused
App event/source/history tests, strict Clippy and a dedicated desktop build.
3. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle,
multi-source review and remaining source/REST parity cases. Still outstanding.
3. All closed constructor forms are implemented, including nested MessageKind.
Independent keyed and ordered facts, reset, complete artifact lifecycle,
multi-source review and remaining source/REST parity cases are outstanding.
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
## Verification
Verified against core `6bb2583`, using only the dedicated desktop target:
Scalar fixes were verified on SEM `6ad3cb`; the nested message-kind slice builds
on scalar commit `5a51a75`. Verification uses only the dedicated desktop target
(core behavior remains based on `6bb2583`):
```powershell
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
@@ -51,7 +56,8 @@ 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**.
All **285 desktop tests passed**, including **11 original semantic review tests**,
**5 scalar review regression tests**, and **8 nested message-kind tests**.
Strict Clippy and the desktop build passed. The existing migration test's header
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
@@ -64,19 +70,222 @@ 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.
The independent scalar review found three gaps, now corrected:
- Structural scalar edits replace only their authorized subtree. Clearing an
invalid selected bound, changing a variant, and adding/removing/reordering a
literal retain untouched raw inputs and editor buffers, including invalid
inputs belonging to another decision.
- Dependent default, reference and port controls read a noncommitting context
composed by the same scalar Keep/Take/Candidate assembler as the request. Raw
candidates remain separate. Incomplete inputs use their stored shape only for
those controls; request creation and Apply still reject invalid selected input.
The Boolean-source/Integer-target default reproduction now offers Boolean
literals in both directions. Tests also cover Candidate type, Keep reversion,
storage-dependent default controls and mechanism-dependent reference choices.
- Keyed fact headings display live names with stable aliases or deleted UUIDs.
Message annotation headings include the complete qualified path and field.
The regression tests exercise App events, the actual default-form helpers and
button availability, exact source retention before Apply, and one-step undo.
## Bounded constructor slice
Occurrence.MessageKind is a complete closed nested family: Asynchronous, Call,
Reply, Create, Delete and Timer. It is available only when both traced counterparts
are Exchange occurrences. The only constructor-specific payload is the timer
expression. This first slice left outer occurrence replacement read-only; the later closed
constructor slice below now covers it.
- Keep/Take/Candidate use the core's switch and first-equal acknowledgment policy.
Taking a replacing group copies the typed MessageKind, including its expression.
Taking an initial equal discriminator does not copy an unselected timer value.
- Candidate MessageKind has its own scoped draft. The independent TimerExpression
scalar draft survives group decisions; the group timer's exact raw text, editor
cursor and selection survive switching away from Timer and back.
- An overlapping kind/timer draft retains both decisions and blocks Apply until
explicitly resolved. The dialog offers **Replace timer decision with message-kind
group** and **Leave message kind unselected; use timer decision**. First equal-kind
acknowledgment can coexist with an independent timer decision. Divergent authored
candidate values reject; neither candidate silently overwrites the other.
- The kind detail includes baseline/current timer observations and the complete
proposed kind. It displays the exact plan-provided acknowledged/retired lists.
Exchange, reply-to, lifelines, annotations and pre/postconditions stay separately
scoped. Incompatible Timer contracts remain core errors; no dependency or payload
is silently removed. Existing additional-impact approval remains in force.
- Group changes compose with scalar choices and execute the exact prepared Document
batch. Tests cover both directions, all six kinds, mapped reply correlation,
historical retirement, repeated no-switch rejection, invalid combinations,
protected scopes, stale preview, source failure, comments and one-step undo/redo.
- The Traceability contextual toolbar now wraps at the available width so +Artifact
remains reachable in the compact layout. This is a single `row.wrap()` change in
`views.rs`; other toolbars are outside this slice.
Root reported native scalar coverage on frozen integrated `e7fa061`: mixed numeric
Keep/Take/Candidate, invalid default retention/repair, zero additional changes,
atomic Apply, comment/value inspection, Undo/Redo and Save All at 1440×900 and
1100×700. Reverse/type/reference/history/reopen native checks were still continuing
when this slice was implemented. This task launched no native application.
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
Milestone 3 must add independent keyed and ordered decisions, historical pairing
reset, complete artifact lifecycle and multi-source review. These facts are currently inspectable, with unsupported writes visibly
identified. The reference table supplies explicit mappings for scalar reference
Take; it does not claim to complete a reset or establish keyed acknowledgments.
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.
Fresh independent requirements/correctness review and root-owned native checks of
the message-kind slice are required before release acceptance. Verify the compact
kind picker, timer text/focus retention, overlap choices, exact closure/retirement,
mapped Reply, blocked Timer contract, source reopening and toolbar wrapping.
## Remaining closed constructor families (this slice)
Owned files: `semantic_review/**`, `semantic_review.rs`, the scoped Draft adapter
in `forms.rs`, semantic-only port choices in `forms/port_fields.rs`, and this ledger.
No shared traceability header, core, API, native state or source language edits.
Base: `f73551f`; same exclusive desktop target.
| Constructor | Complete group payload | Independent fields |
| --- | --- | --- |
| Data | Definition, bounds, literal members, record supertype, default | Unit, owner and headers |
| Port | Contract, function item members, retained legacy direction/interface | Owner and headers |
| Occurrence | Body, nested message kind/timer, references, qualified annotations, bindings, branch and step order | Pre/postconditions, owner and headers |
| Event | Body, signal item members, time kind/expression or change expression | Owner and headers |
| MessageKind | Complete nested kind and timer expression | Other occurrence body properties |
Each group owns an independent draft, including the nested MessageKind group.
Keep/Take/leave-unselected retain all drafts. Constructor picker changes save the
old branch value plus its raw inputs and editor state. Switching back restores them.
Port input/output changes also carry authored item reference strings, including
invalid strings, rather than falling back to stored UUIDs. A Data definition switch
leaves its existing default visible until the user deliberately changes or clears
it; Port's retained generic association has explicit add/edit/clear controls.
The same combined candidate projection supplies dependent form context. Only the
selected roots are materialized; unrelated invalid scalar/group drafts stay local.
Full Take payloads use the public core typed reference visitor, so UUID-shaped prose
is never remapped. For historical same-kind Take, the closed adapter retains only
current target-present/source-absent rows without a previous source-present
observation on that target slot; the core independently
checks the supplied final record. No GUI acknowledgment, retirement or impact list
is synthesized. The core remains authoritative for those lists and applicability.
Group details show every baseline/current/proposed group observation, including
qualified and historical row identities. Actual preview acknowledged/retired lists
remain explicit. A replacing group and selected descendants retain both decisions
and provide explicit leave-unselected actions. First-equal discriminator observation
can coexist with independent descendant decisions. Conflicting authored values
reject instead of silently discarding either draft's edits.
Array controls support the whole authorized payload, including order, existing
owned branches/steps, signal items, annotations and scenario bindings. They do not
create referenced semantic objects, change their ownership, delete dependencies,
or infer reference correspondences. Incompatible payload/owner combinations stay
visible as validation failures. Apply still sends exactly one prepared Document
batch, preserving comments, source/model/history agreement and one-step undo/redo.
New coverage exercises all six Data definitions and companion defaults in both
directions; all eight Occurrence and all three Event kinds; all four Port forms;
combined owner context; nested and scalar draft retention; explicit overlap;
protected fields; mapped record, state, event item and scenario binding payloads;
complete owned branch/step order; historical retirement and first-equal scope;
90 Data/Event kind-pair and direction comparisons against the core-only preview;
source comments/reopen and atomic undo/redo. Full checks and core dependency status
will be recorded at freeze. This task launches no native application.
The delegated keyed-closure fix `43370de` is integrated into SEM as `9c3184b`.
Reverse Literal/Member/Binding/Annotation closure and historical local-member
preservation now pass the desktop desired-success tests. The separately delegated existing-target Port fix `8a75294` is integrated into
SEM as `7512ac8`. Fresh reverse Keep and Candidate reviews can preserve/edit the
OA Legacy association; taking a typed contract into OA still rejects. Both fixes
are covered by desired-success desktop tests; no GUI workaround weakens validation.
Native acceptance remains root-owned: use the existing trace Review semantics
entry, inspect complete group before/after and retired identities, switch variants
away/back, resolve explicit nested overlaps, repair invalid defaults/references,
apply once, inspect source, undo/redo, save and reopen at 1100×700 and 1440×900.
## Closed constructor freeze verification
On integrated SEM base `7512ac8`, all **299 desktop tests pass**. This adds 13
constructor workflow tests and one scoped port form regression to the previous
285-test slice. The new suite includes 90 complete Data/Event kind-pair/direction
comparisons against core previews, all closed candidate kinds, mapped qualified
annotation addresses, bindings and references, actual reverse Legacy edits, owned
branch/step ordering, source/model/history agreement, retained drafts and strict
scope protection. Both delegated core defects are resolved and exercised.
Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and the
desktop build all pass using the dedicated target and commands above. Native checks remain root-owned. No native application
was launched, and full milestone 3 / GUI parity / release acceptance remain open.
## 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.