Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb01ae807 | ||
|
|
c149f2f74e | ||
|
|
7512ac8950 | ||
|
|
9c3184bf21 | ||
|
|
f73551fddc | ||
|
|
5a51a7572e |
@@ -1,5 +1,5 @@
|
|||||||
use super::catalog::{capture_observation, collection, is_constructor, replace_observation, sides};
|
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::mutate::*;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::model::*;
|
use crate::model::*;
|
||||||
@@ -297,16 +297,22 @@ fn apply_canonical_slot(
|
|||||||
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
let fact = replace_observation(fact, Layer::Sa, desired.clone())?;
|
||||||
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
write_fact(project, owner, &fact, Layer::Sa, Layer::Sa, false)
|
||||||
}
|
}
|
||||||
fn target_only(selector: &FacetSelector, source: Layer, target: Layer) -> bool {
|
fn member_present(fact: &FacetReview, layer: Layer) -> bool {
|
||||||
match selector {
|
fn observed<T>(review: &Reviewed<T>, layer: Layer) -> &Observed<T> {
|
||||||
FacetSelector::Member { keys, .. } | FacetSelector::Binding(keys) => {
|
match layer {
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
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) => {
|
FacetReview::Annotation { review, .. } => {
|
||||||
chosen(keys, source).is_none() && chosen(keys, target).is_some()
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||||
|
}
|
||||||
|
FacetReview::Binding { review, .. } => {
|
||||||
|
matches!(observed(review, layer), Observed::Value(Some(_)))
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
@@ -320,15 +326,19 @@ fn preserve_constructor_members(
|
|||||||
source_layer: Layer,
|
source_layer: Layer,
|
||||||
target_layer: Layer,
|
target_layer: Layer,
|
||||||
) -> Result<(), String> {
|
) -> 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 {
|
for fact in initial_facts {
|
||||||
if !constructor_contains(group, &fact.selector)
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let slot = target_slot(target, &fact.selector, target_layer)?;
|
let slot = target_slot(target, &fact.selector, target_layer)?;
|
||||||
let was_corresponding = baseline.facets.iter().any(|old| {
|
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())
|
&& target_slot(target, &old.selector(), target_layer).ok() == Some(slot.clone())
|
||||||
});
|
});
|
||||||
if was_corresponding {
|
if was_corresponding {
|
||||||
@@ -723,12 +733,28 @@ fn build_review(
|
|||||||
constructor_actions.push((target, selected.clone(), local_record));
|
constructor_actions.push((target, selected.clone(), local_record));
|
||||||
}
|
}
|
||||||
let refreshed = semantic_facets(observed, &trace, &references)?;
|
let refreshed = semantic_facets(observed, &trace, &references)?;
|
||||||
let group_selectors: BTreeSet<_> = refreshed
|
let mut group_selectors: BTreeSet<_> = refreshed
|
||||||
.iter()
|
.iter()
|
||||||
.chain(all.iter())
|
.chain(all.iter())
|
||||||
.map(|f| f.selector.clone())
|
.map(|f| f.selector.clone())
|
||||||
.filter(|f| constructor_contains(selected, f))
|
.filter(|f| constructor_contains(selected, f))
|
||||||
.collect();
|
.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![];
|
let mut group = vec![];
|
||||||
for selector in group_selectors {
|
for selector in group_selectors {
|
||||||
match capture_facet(observed, oa, sa, &selector) {
|
match capture_facet(observed, oa, sa, &selector) {
|
||||||
|
|||||||
@@ -395,11 +395,30 @@ pub fn plan_transition(
|
|||||||
commands: vec![],
|
commands: vec![],
|
||||||
};
|
};
|
||||||
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
|
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)
|
sa_port_omissions(project)
|
||||||
} else {
|
} else {
|
||||||
BTreeSet::new()
|
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();
|
let mut reported_omissions = BTreeSet::new();
|
||||||
if selected.len() != request.sources.len() {
|
if selected.len() != request.sources.len() {
|
||||||
conflict(
|
conflict(
|
||||||
|
|||||||
@@ -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
@@ -39,6 +39,9 @@ pub struct Draft {
|
|||||||
baseline: Value,
|
baseline: Value,
|
||||||
pub inputs: BTreeMap<String, Input>,
|
pub inputs: BTreeMap<String, Input>,
|
||||||
pub exact_strings: bool,
|
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>,
|
port_contract_drafts: BTreeMap<String, Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,9 @@ impl Draft {
|
|||||||
value,
|
value,
|
||||||
inputs: BTreeMap::new(),
|
inputs: BTreeMap::new(),
|
||||||
exact_strings: false,
|
exact_strings: false,
|
||||||
|
context: None,
|
||||||
|
semantic_groups: false,
|
||||||
|
constructor_drafts: BTreeMap::new(),
|
||||||
port_contract_drafts: BTreeMap::new(),
|
port_contract_drafts: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
draft.rebuild();
|
draft.rebuild();
|
||||||
@@ -132,6 +138,16 @@ impl Draft {
|
|||||||
pub fn materialize(&self) -> Result<Value, String> {
|
pub fn materialize(&self) -> Result<Value, String> {
|
||||||
self.materialize_inputs(|_| true)
|
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.
|
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
|
||||||
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
|
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
|
||||||
self.materialize_inputs(|path| {
|
self.materialize_inputs(|path| {
|
||||||
@@ -216,6 +232,166 @@ impl Draft {
|
|||||||
self.rebuild();
|
self.rebuild();
|
||||||
Ok(())
|
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(¤t, &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(¤t));
|
||||||
|
let next_key = (path.to_owned(), tag(&proposed));
|
||||||
|
let mut inputs = BTreeMap::new();
|
||||||
|
for key in self
|
||||||
|
.inputs
|
||||||
|
.keys()
|
||||||
|
.filter(|key| within(key, path))
|
||||||
|
.cloned()
|
||||||
|
.collect::<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(¤t),
|
||||||
|
proposed,
|
||||||
|
&mut BTreeMap::new(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
proposed
|
||||||
|
};
|
||||||
|
(value, BTreeMap::new())
|
||||||
|
};
|
||||||
|
self.shape_scoped(path, value)?;
|
||||||
|
self.inputs.extend(restored);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<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(
|
pub fn array(
|
||||||
&mut self,
|
&mut self,
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -223,36 +399,7 @@ impl Draft {
|
|||||||
direction: i32,
|
direction: i32,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let mut candidate = self.materialize()?;
|
let mut candidate = self.materialize()?;
|
||||||
let items = candidate
|
edit_array(&mut candidate, path, index, direction)?;
|
||||||
.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()),
|
|
||||||
}
|
|
||||||
self.value = candidate;
|
self.value = candidate;
|
||||||
self.rebuild();
|
self.rebuild();
|
||||||
Ok(())
|
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 {
|
fn friendly(name: &str) -> String {
|
||||||
match name {
|
match name {
|
||||||
"id" => "UUID identity".into(),
|
"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.
|
// 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
|
// A current reference is always retained, even while other draft fields make it
|
||||||
// incompatible. Applying the complete draft remains the core validator's job.
|
// 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 identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
|
||||||
let compatible = reference_ids(draft, project, path);
|
let compatible = reference_ids(draft, project, path);
|
||||||
let mut objects = compatible
|
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> {
|
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") {
|
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
|
||||||
let owner = value
|
let owner = value
|
||||||
.get("element")
|
.get("element")
|
||||||
@@ -1156,6 +1353,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
|||||||
"layer" => &["Oa", "Sa"],
|
"layer" => &["Oa", "Sa"],
|
||||||
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
||||||
"storage" => &["Value", "Reference"],
|
"storage" => &["Value", "Reference"],
|
||||||
|
"direction" if path.contains("/Port/legacy/") => &["In", "Out", "InOut"],
|
||||||
"direction" if path.contains("DataField") => {
|
"direction" if path.contains("DataField") => {
|
||||||
&["Unspecified", "In", "Out", "InOut", "Return"]
|
&["Unspecified", "In", "Out", "InOut", "Return"]
|
||||||
}
|
}
|
||||||
@@ -1412,7 +1610,7 @@ fn property<'a>(
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
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);
|
let mut content = column![].spacing(4);
|
||||||
if !value.is_null() {
|
if !value.is_null() {
|
||||||
content = content.push(
|
content = content.push(
|
||||||
@@ -1426,6 +1624,29 @@ fn property<'a>(
|
|||||||
}
|
}
|
||||||
return content.into();
|
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 {
|
let enum_choices = if skip_variant {
|
||||||
vec![]
|
vec![]
|
||||||
} else {
|
} else {
|
||||||
@@ -1683,6 +1904,73 @@ fn property<'a>(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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> {
|
fn ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||||
reference_ids(draft, project, path)
|
reference_ids(draft, project, path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use super::*;
|
|||||||
use iced::widget::column;
|
use iced::widget::column;
|
||||||
|
|
||||||
fn element(draft: &Draft, project: &Project) -> Option<Element> {
|
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()
|
.ok()
|
||||||
.or_else(|| project.elements.get(&draft.original).cloned())
|
.or_else(|| project.elements.get(&draft.original).cloned())
|
||||||
}
|
}
|
||||||
@@ -113,9 +113,9 @@ pub(super) fn property<'a>(
|
|||||||
let minimum_path = path.replace("maximum", "minimum");
|
let minimum_path = path.replace("maximum", "minimum");
|
||||||
json!(
|
json!(
|
||||||
draft
|
draft
|
||||||
.materialize()
|
.context_value()
|
||||||
.ok()
|
.pointer(&minimum_path)
|
||||||
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
|
.and_then(Value::as_u64)
|
||||||
.unwrap_or(1)
|
.unwrap_or(1)
|
||||||
.max(1)
|
.max(1)
|
||||||
)
|
)
|
||||||
@@ -311,7 +311,7 @@ fn message_value<'a>(
|
|||||||
value: &'a Value,
|
value: &'a Value,
|
||||||
path: &str,
|
path: &str,
|
||||||
) -> View<'a, Message> {
|
) -> 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 row = edited.pointer(path).unwrap_or(value).clone();
|
||||||
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
|
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
|
||||||
let owner = element(draft, project);
|
let owner = element(draft, project);
|
||||||
@@ -439,6 +439,257 @@ fn message_value<'a>(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
|
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
|
||||||
let (mut f, next) = crate::data_values::tests::recursive_payload();
|
let (mut f, next) = crate::data_values::tests::recursive_payload();
|
||||||
|
|||||||
@@ -42,10 +42,14 @@ pub(super) fn variants(
|
|||||||
_value: &Value,
|
_value: &Value,
|
||||||
) -> Option<Vec<(String, Value)>> {
|
) -> Option<Vec<(String, Value)>> {
|
||||||
if path.ends_with("/Port/contract") {
|
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();
|
let sa = current.pointer("/body/Sa").is_some();
|
||||||
if !sa {
|
if !sa {
|
||||||
return Some(vec![]);
|
return Some(if draft.semantic_groups {
|
||||||
|
vec![("Legacy generic port".into(), json!("Legacy"))]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let owner = current
|
let owner = current
|
||||||
.get("parent")
|
.get("parent")
|
||||||
@@ -69,6 +73,9 @@ pub(super) fn variants(
|
|||||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if draft.semantic_groups {
|
||||||
|
choices.push(("Legacy generic port".into(), json!("Legacy")));
|
||||||
|
}
|
||||||
return Some(choices);
|
return Some(choices);
|
||||||
}
|
}
|
||||||
if path.ends_with("/Port/contract/Component/kind") {
|
if path.ends_with("/Port/contract/Component/kind") {
|
||||||
@@ -80,7 +87,7 @@ pub(super) fn variants(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if path.ends_with("/Port/contract/Component/orientation") {
|
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 kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
|
||||||
let orientations: &[&str] =
|
let orientations: &[&str] =
|
||||||
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
|
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
|
||||||
|
|||||||
@@ -8,16 +8,28 @@ use oa_core::{transitions::*, *};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
|
mod constructors;
|
||||||
|
mod history;
|
||||||
|
mod keyed;
|
||||||
|
mod message_kind;
|
||||||
mod scalar;
|
mod scalar;
|
||||||
mod view;
|
mod view;
|
||||||
pub(crate) use view::summary;
|
pub(crate) use view::summary;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod constructor_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod keyed_tests;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod message_kind_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
Facts,
|
Facts,
|
||||||
References,
|
References,
|
||||||
|
Members,
|
||||||
|
History,
|
||||||
Impacts,
|
Impacts,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +55,16 @@ pub enum Event {
|
|||||||
RemoveReference(Id),
|
RemoveReference(Id),
|
||||||
AcceptImpact(usize, bool),
|
AcceptImpact(usize, bool),
|
||||||
Preview,
|
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 {
|
pub struct State {
|
||||||
@@ -53,7 +75,19 @@ pub struct State {
|
|||||||
pub selected: Option<FacetSelector>,
|
pub selected: Option<FacetSelector>,
|
||||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||||
pub references: BTreeMap<Id, Id>,
|
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>,
|
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 plan: Option<SemanticTransitionPlan>,
|
||||||
pub proposed: Option<Project>,
|
pub proposed: Option<Project>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
@@ -95,7 +129,17 @@ impl State {
|
|||||||
selected: None,
|
selected: None,
|
||||||
decisions: BTreeMap::new(),
|
decisions: BTreeMap::new(),
|
||||||
references: 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,
|
draft: None,
|
||||||
|
group_drafts: BTreeMap::new(),
|
||||||
plan: None,
|
plan: None,
|
||||||
proposed: None,
|
proposed: None,
|
||||||
error: None,
|
error: None,
|
||||||
@@ -122,15 +166,41 @@ impl State {
|
|||||||
fn load_trace(&mut self, project: &Project) {
|
fn load_trace(&mut self, project: &Project) {
|
||||||
self.facts.clear();
|
self.facts.clear();
|
||||||
self.decisions.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.selected = None;
|
||||||
self.accepted_impacts.clear();
|
self.accepted_impacts.clear();
|
||||||
self.references.clear();
|
self.references.clear();
|
||||||
self.draft = self
|
self.draft = self
|
||||||
.target(project)
|
.target(project)
|
||||||
.and_then(|id| Draft::from_project(project, id));
|
.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)) {
|
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||||
self.references.insert(trace.oa, trace.sa);
|
self.references.insert(trace.oa, trace.sa);
|
||||||
}
|
}
|
||||||
|
self.member_family = self
|
||||||
|
.member_families(project)
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(keyed::Family::Literal);
|
||||||
self.refresh(project);
|
self.refresh(project);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +238,17 @@ impl State {
|
|||||||
{
|
{
|
||||||
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
||||||
} else {
|
} 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);
|
self.decisions.insert(facet, decision);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -184,22 +265,21 @@ impl State {
|
|||||||
d.edit_text(&path, action);
|
d.edit_text(&path, action);
|
||||||
Ok(())
|
Ok(())
|
||||||
}),
|
}),
|
||||||
Event::Shape(path, value) => self.edit(project, &path, |d| d.shape(&path, value)),
|
Event::Shape(path, value) => {
|
||||||
Event::Variant(path, value) => self.edit(project, &path, |d| {
|
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||||
if d.materialize()?
|
}
|
||||||
.pointer(&path)
|
Event::Variant(path, value) => {
|
||||||
.is_some_and(|v| crate::forms::same_variant(v, &value))
|
self.edit(project, &path, |d| d.variant_scoped(&path, value))
|
||||||
{
|
}
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
d.shape(&path, value)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Event::Array(path, index, direction) => {
|
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) => {
|
Event::Tab(tab) => {
|
||||||
self.tab = tab;
|
self.tab = tab;
|
||||||
replan = false;
|
replan = false;
|
||||||
@@ -252,6 +332,57 @@ impl State {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Event::Preview => Ok(()),
|
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 {
|
if let Err(error) = result {
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
@@ -275,15 +406,23 @@ impl State {
|
|||||||
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
||||||
return Err("Choose Set final value before editing this property.".into());
|
return Err("Choose Set final value before editing this property.".into());
|
||||||
}
|
}
|
||||||
let root = self
|
let roots = self
|
||||||
.target(project)
|
.target(project)
|
||||||
.and_then(|id| scalar::binding(project, id, facet))
|
.and_then(|id| constructors::roots(project, id, facet))
|
||||||
.ok_or("Selected scalar property is unavailable.")?;
|
.ok_or("Selected property is unavailable.")?;
|
||||||
if path != root && !path.starts_with(&format!("{root}/")) {
|
if !roots
|
||||||
return Err("Only the selected scalar property can change in this draft.".into());
|
.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.")?;
|
let draft = if scalar::grouped(facet) {
|
||||||
// The scoped view sends only descendants of the selected scalar property.
|
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.
|
// Protected identity and top-level body/perspective controls are never exposed.
|
||||||
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
||||||
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
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 {
|
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)
|
self.target(project)
|
||||||
.and_then(|id| scalar::binding(project, id, facet))
|
.and_then(|id| scalar::binding(project, id, facet))
|
||||||
.is_some()
|
.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 {
|
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||||
if !scalar::grouped(facet) {
|
if !scalar::grouped(facet) {
|
||||||
return false;
|
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;
|
return false;
|
||||||
};
|
};
|
||||||
if trace
|
if trace
|
||||||
@@ -313,13 +470,12 @@ impl State {
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
self.facts
|
capture_facet(project, trace.oa, trace.sa, facet)
|
||||||
.iter()
|
.ok()
|
||||||
.find(|f| f.selector == *facet)
|
.is_some_and(|current| {
|
||||||
.is_some_and(|f| {
|
scalar::value(¤t, Layer::Oa).is_some()
|
||||||
scalar::value(&f.current, Layer::Oa).is_some()
|
&& scalar::observation(¤t, Layer::Oa)
|
||||||
&& scalar::observation(&f.current, Layer::Oa)
|
== scalar::observation(¤t, Layer::Sa)
|
||||||
== scalar::observation(&f.current, Layer::Sa)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,7 +508,7 @@ impl State {
|
|||||||
decision: *decision,
|
decision: *decision,
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
reset: vec![],
|
reset: self.resets.iter().cloned().collect(),
|
||||||
});
|
});
|
||||||
request.accepted_impacts = self.accepted_impacts.clone();
|
request.accepted_impacts = self.accepted_impacts.clone();
|
||||||
if self
|
if self
|
||||||
@@ -360,53 +516,7 @@ impl State {
|
|||||||
.values()
|
.values()
|
||||||
.any(|d| *d == SemanticDecision::Candidate)
|
.any(|d| *d == SemanticDecision::Candidate)
|
||||||
{
|
{
|
||||||
let original =
|
let final_value = self.combined_value(project, false)?;
|
||||||
Draft::from_project(project, target).ok_or("Target has no editable record.")?;
|
|
||||||
let mut final_value = original.value;
|
|
||||||
let scopes: Vec<_> = self
|
|
||||||
.decisions
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, decision)| **decision == SemanticDecision::Candidate)
|
|
||||||
.filter_map(|(facet, _)| scalar::binding(project, target, facet))
|
|
||||||
.collect();
|
|
||||||
let draft = self
|
|
||||||
.draft
|
|
||||||
.as_ref()
|
|
||||||
.ok_or("Target draft is unavailable.")?
|
|
||||||
.materialize_scopes(&scopes)?;
|
|
||||||
let mut map = BTreeMap::new();
|
|
||||||
for (oa, sa) in &self.references {
|
|
||||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
|
||||||
(*oa, *sa)
|
|
||||||
} else {
|
|
||||||
(*sa, *oa)
|
|
||||||
};
|
|
||||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
|
||||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map.insert(source, target);
|
|
||||||
for (facet, decision) in &self.decisions {
|
|
||||||
if *decision == SemanticDecision::KeepTarget {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let path = scalar::binding(project, target, facet)
|
|
||||||
.ok_or("Selected property is not applicable to the target.")?;
|
|
||||||
let value = match decision {
|
|
||||||
SemanticDecision::KeepTarget => continue,
|
|
||||||
SemanticDecision::Candidate => draft
|
|
||||||
.pointer(&path)
|
|
||||||
.cloned()
|
|
||||||
.ok_or("Candidate property is no longer available.")?,
|
|
||||||
SemanticDecision::TakeSource => {
|
|
||||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
|
||||||
scalar::source_value(&fact, self.direction.source_layer(), &map)?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
*final_value
|
|
||||||
.pointer_mut(&path)
|
|
||||||
.ok_or("Final target property is unavailable.")? = value;
|
|
||||||
}
|
|
||||||
let candidate = if project.elements.contains_key(&target) {
|
let candidate = if project.elements.contains_key(&target) {
|
||||||
SemanticRecord::Element(
|
SemanticRecord::Element(
|
||||||
serde_json::from_value(final_value)
|
serde_json::from_value(final_value)
|
||||||
@@ -423,19 +533,156 @@ impl State {
|
|||||||
Ok(request)
|
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) {
|
pub fn refresh(&mut self, project: &Project) {
|
||||||
self.plan = None;
|
self.plan = None;
|
||||||
self.proposed = None;
|
self.proposed = None;
|
||||||
self.error = 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(
|
self.error = Some(
|
||||||
"Several traces are available. Choose the correspondence and review history above."
|
"Several traces are available. Choose the correspondence and review history above."
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
match semantic_facets(project, trace, &self.references) {
|
match semantic_facets(project, &trace, &self.references) {
|
||||||
Ok(facts) => self.facts = facts,
|
Ok(mut facts) => {
|
||||||
|
// Explicit addresses survive catalog normalization, pairing changes and resets.
|
||||||
|
for selector in self.staged.iter().chain(self.decisions.keys()) {
|
||||||
|
if !facts.iter().any(|f| &f.selector == selector) {
|
||||||
|
match capture_facet(project, trace.oa, trace.sa, selector) {
|
||||||
|
Ok(current) => {
|
||||||
|
let previous = trace
|
||||||
|
.baseline
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.semantic.as_ref())
|
||||||
|
.and_then(|b| {
|
||||||
|
b.facets.iter().find(|f| f.selector() == *selector)
|
||||||
|
});
|
||||||
|
facts.push(FacetDrift {
|
||||||
|
selector: selector.clone(),
|
||||||
|
drift: facet_drift(¤t, previous),
|
||||||
|
current,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.error = Some(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
facts.sort_by(|a, b| a.selector.cmp(&b.selector));
|
||||||
|
self.facts = facts;
|
||||||
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.error = Some(error);
|
self.error = Some(error);
|
||||||
return;
|
return;
|
||||||
@@ -475,8 +722,11 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||||
if self.decisions.is_empty() {
|
if self.decisions.is_empty() && self.resets.is_empty() {
|
||||||
return Err("Choose at least one property to review.".into());
|
return Err(
|
||||||
|
"Choose at least one property to review or an exact history observation to reset."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if let Some(error) = &self.error {
|
if let Some(error) = &self.error {
|
||||||
return Err(error.clone());
|
return Err(error.clone());
|
||||||
@@ -490,7 +740,7 @@ impl State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn can_apply(&self) -> bool {
|
pub fn can_apply(&self) -> bool {
|
||||||
!self.decisions.is_empty()
|
(!self.decisions.is_empty() || !self.resets.is_empty())
|
||||||
&& self.error.is_none()
|
&& self.error.is_none()
|
||||||
&& self
|
&& self
|
||||||
.plan
|
.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(¤t, Layer::Oa) != scalar::observation(¤t, Layer::Sa) {
|
||||||
|
return Ok(value);
|
||||||
|
}
|
||||||
|
let references = if direction == TransitionDirection::OaToSa {
|
||||||
|
map.clone()
|
||||||
|
} else {
|
||||||
|
map.iter().map(|(sa, oa)| (*oa, *sa)).collect()
|
||||||
|
};
|
||||||
|
let old = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref());
|
||||||
|
for fact in semantic_facets(project, trace, &references)? {
|
||||||
|
if !contains(facet, &fact.selector) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((path, key)) =
|
||||||
|
target_row(project, target, &fact.selector, direction.target_layer())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if row_present(&fact.current, direction.source_layer())
|
||||||
|
|| !row_present(&fact.current, direction.target_layer())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let was_paired = old.is_some_and(|b| {
|
||||||
|
b.facets.iter().any(|previous| {
|
||||||
|
row_present(previous, direction.source_layer())
|
||||||
|
&& target_row(
|
||||||
|
project,
|
||||||
|
target,
|
||||||
|
&previous.selector(),
|
||||||
|
direction.target_layer(),
|
||||||
|
)
|
||||||
|
.as_ref()
|
||||||
|
== Some(&(path.clone(), key.clone()))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if was_paired {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let row = scalar::value(&fact.current, direction.target_layer())
|
||||||
|
.ok_or("Target row is inapplicable.")?;
|
||||||
|
let items = value
|
||||||
|
.pointer_mut(&path)
|
||||||
|
.and_then(Value::as_array_mut)
|
||||||
|
.ok_or("Target collection is unavailable.")?;
|
||||||
|
match &fact.selector {
|
||||||
|
FacetSelector::Member { .. } | FacetSelector::Literal(_) => {
|
||||||
|
if row == Value::Bool(true) && !items.contains(&key) {
|
||||||
|
items.push(key);
|
||||||
|
} else if row == Value::Bool(false) {
|
||||||
|
items.retain(|v| v != &key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FacetSelector::Annotation(_) => {
|
||||||
|
items.retain(|v| {
|
||||||
|
serde_json::from_value::<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,13 @@ use super::*;
|
|||||||
use crate::{app::App, document::Document, workflow::Workflow};
|
use crate::{app::App, document::Document, workflow::Workflow};
|
||||||
use serde_json::json;
|
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)));
|
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));
|
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 {
|
let Some(Workflow::Semantic(state)) = &app.workflow else {
|
||||||
panic!("Semantic workflow missing: {}", app.status)
|
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");
|
let trace = add_trace(&mut p, oa, sa, "Range rationale");
|
||||||
(p, oa, sa, trace)
|
(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();
|
let id = Id::new_v4();
|
||||||
p.traces.insert(
|
p.traces.insert(
|
||||||
id,
|
id,
|
||||||
@@ -52,13 +52,217 @@ fn add_trace(p: &mut Project, oa: Id, sa: Id, name: &str) -> Id {
|
|||||||
);
|
);
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
fn start(app: &mut App, source: Id) {
|
pub(super) fn start(app: &mut App, source: Id) {
|
||||||
let _ = app.update(Message::StartSemanticReview(source));
|
let _ = app.update(Message::StartSemanticReview(source));
|
||||||
}
|
}
|
||||||
fn facet(value: DataFacet) -> FacetSelector {
|
fn facet(value: DataFacet) -> FacetSelector {
|
||||||
FacetSelector::Data(value)
|
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::Select(selected.clone()));
|
||||||
send(app, Event::Decide(selected, Some(decision)));
|
send(app, Event::Decide(selected, Some(decision)));
|
||||||
}
|
}
|
||||||
@@ -182,7 +386,7 @@ fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
|
|||||||
.error
|
.error
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains("selected scalar")
|
.contains("selected review")
|
||||||
);
|
);
|
||||||
assert_eq!(app.project(), &before.decoded.project);
|
assert_eq!(app.project(), &before.decoded.project);
|
||||||
send(&mut app, Event::Preview);
|
send(&mut app, Event::Preview);
|
||||||
@@ -364,7 +568,10 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() {
|
|||||||
send(
|
send(
|
||||||
&mut app,
|
&mut app,
|
||||||
Event::Decide(
|
Event::Decide(
|
||||||
facet(DataFacet::Constructor),
|
FacetSelector::Literal(KeyPair {
|
||||||
|
oa: Some("Unpaired".into()),
|
||||||
|
sa: None,
|
||||||
|
}),
|
||||||
Some(SemanticDecision::TakeSource),
|
Some(SemanticDecision::TakeSource),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,40 +71,60 @@ pub(super) fn group(facet: &FacetSelector) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
||||||
let keys = |keys: &KeyPair<Id>| {
|
let keys = |keys: &KeyPair<Id>, missing: &str| {
|
||||||
format!(
|
format!(
|
||||||
"{} ↔ {}",
|
"{} ↔ {}",
|
||||||
keys.oa.map(|id| project.named(id)).unwrap_or("Absent"),
|
keys.oa
|
||||||
keys.sa.map(|id| project.named(id)).unwrap_or("Absent")
|
.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 {
|
match facet {
|
||||||
FacetSelector::Member {
|
FacetSelector::Member {
|
||||||
collection,
|
collection,
|
||||||
keys: pair,
|
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::Order(collection) => format!("{} order", words(&format!("{collection:?}"))),
|
||||||
FacetSelector::Literal(pair) => format!(
|
FacetSelector::Literal(pair) => format!(
|
||||||
"Literal · {} ↔ {}",
|
"Literal · {} ↔ {}",
|
||||||
pair.oa.as_deref().unwrap_or("Absent"),
|
pair.oa
|
||||||
pair.sa.as_deref().unwrap_or("Absent")
|
.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!(
|
FacetSelector::Annotation(pair) => format!(
|
||||||
"Message value · {} ↔ {}",
|
"Message value · {} ↔ {}",
|
||||||
pair.oa
|
pair.oa
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|key| project.named(key.field))
|
.map(|key| annotation_title(project, key))
|
||||||
.unwrap_or("Absent"),
|
.unwrap_or_else(|| "Absent".into()),
|
||||||
pair.sa
|
pair.sa
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|key| project.named(key.field))
|
.map(|key| annotation_title(project, key))
|
||||||
.unwrap_or("Absent")
|
.unwrap_or_else(|| "Absent".into())
|
||||||
),
|
),
|
||||||
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
|
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair, "Absent")),
|
||||||
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
|
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair, "Absent")),
|
||||||
FacetSelector::Incident { keys: pair, end } => format!("Incident {end:?} · {}", keys(pair)),
|
FacetSelector::Incident { keys: pair, end } => {
|
||||||
|
format!("Incident {end:?} · {}", keys(pair, "Absent"))
|
||||||
|
}
|
||||||
FacetSelector::Artifact { keys: pair, facet } => {
|
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");
|
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 {
|
fn drift(drift: Drift) -> &'static str {
|
||||||
match drift {
|
match drift {
|
||||||
Drift::Unreviewed => "Unreviewed",
|
Drift::Unreviewed => "Unreviewed",
|
||||||
@@ -187,6 +215,12 @@ fn observed(project: &Project, fact: Option<&FacetReview>, layer: Layer) -> Stri
|
|||||||
let value = scalar::observation(fact, layer);
|
let value = scalar::observation(fact, layer);
|
||||||
if value.as_str() == Some("NotApplicable") {
|
if value.as_str() == Some("NotApplicable") {
|
||||||
"Not applicable".into()
|
"Not applicable".into()
|
||||||
|
} else if keyed::supported(&fact.selector()) {
|
||||||
|
if value["Value"].as_bool() == Some(true) {
|
||||||
|
"Present".into()
|
||||||
|
} else {
|
||||||
|
"Absent".into()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
formatted(project, &value["Value"], &scalar::references(fact))
|
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 })),
|
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
||||||
].spacing(8),
|
].spacing(8),
|
||||||
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
|
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);
|
].spacing(8);
|
||||||
let body = match self.tab {
|
let body = match self.tab {
|
||||||
Tab::Facts => self.facts_view(project),
|
Tab::Facts => self.facts_view(project),
|
||||||
Tab::References => self.references_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),
|
Tab::Impacts => self.impacts_view(project),
|
||||||
};
|
};
|
||||||
content = content.push(body);
|
content = content.push(body);
|
||||||
@@ -254,7 +290,7 @@ impl State {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.drift == Drift::Unreviewed)
|
.filter(|f| f.drift == Drift::Unreviewed)
|
||||||
.count();
|
.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 {
|
if let Some(trace) = trace {
|
||||||
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
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(|t| t.baseline.as_ref())
|
||||||
.and_then(|b| b.semantic.as_ref())
|
.and_then(|b| b.semantic.as_ref())
|
||||||
.and_then(|b| b.facets.iter().find(|f| f.selector() == fact.selector));
|
.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 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![
|
column![
|
||||||
text(format!(
|
text(format!(
|
||||||
"{label}{}",
|
"{label}{}",
|
||||||
@@ -365,9 +437,9 @@ impl State {
|
|||||||
.size(12)
|
.size(12)
|
||||||
.color(style::TEAL),
|
.color(style::TEAL),
|
||||||
text("Previously reviewed").size(10).color(style::MUTED),
|
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("Current").size(10).color(style::MUTED),
|
||||||
text(observed(project, Some(&fact.current), layer)).size(12),
|
text(current).size(12),
|
||||||
]
|
]
|
||||||
.spacing(5)
|
.spacing(5)
|
||||||
.width(Fill)
|
.width(Fill)
|
||||||
@@ -378,6 +450,38 @@ impl State {
|
|||||||
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
||||||
]
|
]
|
||||||
.spacing(10);
|
.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
|
if let Some(proposed) = &self.proposed
|
||||||
&& let Some(trace) = trace
|
&& let Some(trace) = trace
|
||||||
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
&& let Ok(after) = capture_facet(proposed, trace.oa, trace.sa, &fact.selector)
|
||||||
@@ -389,11 +493,20 @@ impl State {
|
|||||||
.color(style::TEAL),
|
.color(style::TEAL),
|
||||||
)
|
)
|
||||||
.push(
|
.push(
|
||||||
text(observed(
|
text(if is_group {
|
||||||
proposed,
|
semantic_facets(proposed, &proposed.traces[&trace.id], &self.references)
|
||||||
Some(&after),
|
.ok()
|
||||||
self.direction.target_layer(),
|
.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),
|
.size(12),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -404,6 +517,13 @@ impl State {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if self.editable(project, &fact.selector) {
|
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 {
|
let destination = if self.direction.target_layer() == Layer::Sa {
|
||||||
"System"
|
"System"
|
||||||
} else {
|
} else {
|
||||||
@@ -426,30 +546,120 @@ impl State {
|
|||||||
]
|
]
|
||||||
.spacing(5);
|
.spacing(5);
|
||||||
buttons = buttons.push(action(
|
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)),
|
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
||||||
));
|
));
|
||||||
detail = detail.push(buttons).push(action(
|
detail = detail.push(buttons.wrap()).push(action(
|
||||||
"Leave unselected",
|
"Leave unselected",
|
||||||
Event::Decide(fact.selector.clone(), None),
|
Event::Decide(fact.selector.clone(), None),
|
||||||
));
|
));
|
||||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||||
&& let Some(draft) = &self.draft
|
&& keyed::supported(&fact.selector)
|
||||||
&& let Some(path) = self
|
|
||||||
.target(project)
|
|
||||||
.and_then(|id| scalar::binding(project, id, &fact.selector))
|
|
||||||
{
|
{
|
||||||
detail = detail.push(text("Final destination value").size(12)).push(
|
let facet = fact.selector.clone();
|
||||||
crate::forms::scoped(draft, project, &path, &title(project, &fact.selector))
|
detail = detail.push(
|
||||||
.map(form_event),
|
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) {
|
} 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))
|
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("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
||||||
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
||||||
} else {
|
} 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!(
|
if matches!(
|
||||||
fact.selector,
|
fact.selector,
|
||||||
@@ -462,10 +672,106 @@ impl State {
|
|||||||
detail.into()
|
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> {
|
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||||
let mut content = column![
|
let mut content = column![
|
||||||
text("Reference correspondence · always Operational → System").size(15),
|
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),
|
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
|
||||||
row![
|
row![
|
||||||
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
||||||
|
|||||||
@@ -744,6 +744,7 @@ fn traces(app: &App) -> View<'_, Message> {
|
|||||||
action("+ Artifact", Message::AddArtifact)
|
action("+ Artifact", Message::AddArtifact)
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
|
.wrap()
|
||||||
]
|
]
|
||||||
.spacing(16);
|
.spacing(16);
|
||||||
for t in p.traces.values() {
|
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
@@ -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
|
Status: milestones 1–2 and the closed constructor group forms are implemented.
|
||||||
parity, native acceptance or release acceptance.
|
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:
|
Base: `d130013`. Dedicated build target:
|
||||||
`D:/CodexBuilds/Capella2-semantic-desktop-target`. Native verification is root-owned.
|
`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
|
- Support every applicable scalar leaf through Keep, Take and typed final-value
|
||||||
forms. Candidate records contain the complete combined selected result. Identity,
|
forms. Candidate records contain the complete combined selected result. Identity,
|
||||||
alias and layer are protected. The closed core remains the validation boundary.
|
alias and layer are protected. The closed core remains the validation boundary.
|
||||||
- State.Kind is a single structural scalar. Other constructor switches, keyed
|
- State.Kind is a single structural scalar. Data, Port, Occurrence, nested
|
||||||
membership/order/annotation/binding/attachment decisions and pairing resets belong
|
MessageKind and Event support their closed constructor groups and complete
|
||||||
to milestone 3; show them read-only with an honest explanation.
|
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
|
- Core `6bb2583` permits first equal constructor acknowledgment without descendant
|
||||||
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
|
expansion. The GUI exposes **Acknowledge equal kind only** when applicable;
|
||||||
previously reviewed constructors remain governed by the core's switch rules.
|
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
|
2. Scalar decisions and typed candidates; immutable initial observations; combined
|
||||||
final records; exact impact acceptance; atomic document/source commit. Focused
|
final records; exact impact acceptance; atomic document/source commit. Focused
|
||||||
App event/source/history tests, strict Clippy and a dedicated desktop build.
|
App event/source/history tests, strict Clippy and a dedicated desktop build.
|
||||||
3. Constructor closure, keyed and ordered facts, reset, complete artifact lifecycle,
|
3. All closed constructor forms are implemented, including nested MessageKind.
|
||||||
multi-source review and remaining source/REST parity cases. Still outstanding.
|
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.
|
4. Root-owned native review, cross-surface fixtures, package/release acceptance.
|
||||||
|
|
||||||
## Verification
|
## 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
|
```powershell
|
||||||
$env:CARGO_TARGET_DIR='D:/CodexBuilds/Capella2-semantic-desktop-target'
|
$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
|
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
|
Strict Clippy and the desktop build passed. The existing migration test's header
|
||||||
assertion now follows `SCHEMA_VERSION` instead of expecting schema 4.
|
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
|
inherited-annotation cleanup approval, cleared when decisions change. Unselected
|
||||||
invalid scalar drafts remain retained without contaminating another candidate.
|
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:
|
Built executable:
|
||||||
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
|
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
|
||||||
The build target is idle. No native application was launched by this task.
|
The build target is idle. No native application was launched by this task.
|
||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
Milestone 3 must add explicit constructor closure/replacement, keyed and ordered
|
Milestone 3 must add independent keyed and ordered decisions, historical pairing
|
||||||
decisions, historical pairing reset, complete artifact lifecycle and multi-source
|
reset, complete artifact lifecycle and multi-source review. These facts are currently inspectable, with unsupported writes visibly
|
||||||
review. These facts are currently inspectable, with unsupported writes visibly
|
|
||||||
identified. The reference table supplies explicit mappings for scalar reference
|
identified. The reference table supplies explicit mappings for scalar reference
|
||||||
Take; it does not claim to complete a reset or establish keyed acknowledgments.
|
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
|
Fresh independent requirements/correctness review and root-owned native checks of
|
||||||
required before merging this milestone into release acceptance. In particular,
|
the message-kind slice are required before release acceptance. Verify the compact
|
||||||
validate the 1100×700 layout, keyboard/focus continuity, long historical values,
|
kind picker, timer text/focus retention, overlap choices, exact closure/retirement,
|
||||||
reference selection, source reopening and prepared-impact inspection in the app.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user