Add exact semantic history resets and unordered member review
This commit is contained in:
@@ -718,7 +718,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
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use serde_json::Value;
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
mod constructors;
|
mod constructors;
|
||||||
|
mod history;
|
||||||
|
mod keyed;
|
||||||
mod message_kind;
|
mod message_kind;
|
||||||
mod scalar;
|
mod scalar;
|
||||||
mod view;
|
mod view;
|
||||||
@@ -16,6 +18,8 @@ pub(crate) use view::summary;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod constructor_tests;
|
mod constructor_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod keyed_tests;
|
||||||
|
#[cfg(test)]
|
||||||
mod message_kind_tests;
|
mod message_kind_tests;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
@@ -24,6 +28,8 @@ mod tests;
|
|||||||
pub enum Tab {
|
pub enum Tab {
|
||||||
Facts,
|
Facts,
|
||||||
References,
|
References,
|
||||||
|
Members,
|
||||||
|
History,
|
||||||
Impacts,
|
Impacts,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,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 {
|
||||||
@@ -59,6 +75,15 @@ 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.
|
// Each closed constructor has an independent draft, including nested kinds.
|
||||||
// Scalar and sibling drafts survive decision and constructor changes.
|
// Scalar and sibling drafts survive decision and constructor changes.
|
||||||
@@ -104,6 +129,15 @@ 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(),
|
group_drafts: BTreeMap::new(),
|
||||||
plan: None,
|
plan: None,
|
||||||
@@ -132,6 +166,14 @@ 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();
|
||||||
@@ -154,6 +196,11 @@ impl State {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,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(())
|
||||||
}
|
}
|
||||||
@@ -274,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);
|
||||||
@@ -322,6 +431,11 @@ 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()
|
||||||
@@ -345,7 +459,7 @@ impl State {
|
|||||||
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
|
||||||
@@ -394,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
|
||||||
@@ -423,10 +537,7 @@ impl State {
|
|||||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||||
/// request construction always requires every selected input to parse.
|
/// request construction always requires every selected input to parse.
|
||||||
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
||||||
let trace = self
|
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||||
.trace
|
|
||||||
.and_then(|id| project.traces.get(&id))
|
|
||||||
.ok_or("Choose a trace.")?;
|
|
||||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||||
(trace.oa, trace.sa)
|
(trace.oa, trace.sa)
|
||||||
} else {
|
} else {
|
||||||
@@ -462,6 +573,10 @@ impl State {
|
|||||||
if *decision == SemanticDecision::KeepTarget {
|
if *decision == SemanticDecision::KeepTarget {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if keyed::supported(facet) {
|
||||||
|
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let roots = constructors::roots(project, target, facet)
|
let roots = constructors::roots(project, target, facet)
|
||||||
.ok_or("Selected property is not applicable to the target.")?;
|
.ok_or("Selected property is not applicable to the target.")?;
|
||||||
let supplied = match decision {
|
let supplied = match decision {
|
||||||
@@ -480,7 +595,7 @@ impl State {
|
|||||||
if self.can_acknowledge_constructor(project, facet) {
|
if self.can_acknowledge_constructor(project, facet) {
|
||||||
original.clone()
|
original.clone()
|
||||||
} else {
|
} else {
|
||||||
constructors::take_value(project, trace, self.direction, facet, &map)?
|
constructors::take_value(project, &trace, self.direction, facet, &map)?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SemanticDecision::TakeSource => {
|
SemanticDecision::TakeSource => {
|
||||||
@@ -531,15 +646,43 @@ impl State {
|
|||||||
for draft in self.group_drafts.values_mut() {
|
for draft in self.group_drafts.values_mut() {
|
||||||
draft.set_context(context.clone());
|
draft.set_context(context.clone());
|
||||||
}
|
}
|
||||||
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
let Some(trace) = self.effective_trace(project) else {
|
||||||
self.error = Some(
|
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;
|
||||||
@@ -579,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());
|
||||||
@@ -594,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
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -71,27 +71,37 @@ 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
|
keys.oa
|
||||||
.map(|id| choice(project, id).label)
|
.map(|id| choice(project, id).label)
|
||||||
.unwrap_or_else(|| "Absent".into()),
|
.unwrap_or_else(|| missing.into()),
|
||||||
keys.sa
|
keys.sa
|
||||||
.map(|id| choice(project, id).label)
|
.map(|id| choice(project, id).label)
|
||||||
.unwrap_or_else(|| "Absent".into())
|
.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 · {} ↔ {}",
|
||||||
@@ -104,11 +114,17 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
|||||||
.map(|key| annotation_title(project, key))
|
.map(|key| annotation_title(project, key))
|
||||||
.unwrap_or_else(|| "Absent".into())
|
.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");
|
||||||
@@ -199,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))
|
||||||
}
|
}
|
||||||
@@ -237,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);
|
||||||
@@ -266,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 · Independent keyed/ordered decisions and historical reset remain pending.", self.decisions.len())).size(10).color(style::MUTED));
|
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Ordered, annotation, binding and attached-record decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
|
||||||
if let Some(trace) = trace {
|
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));
|
||||||
}
|
}
|
||||||
@@ -426,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)
|
||||||
@@ -501,6 +557,19 @@ impl State {
|
|||||||
"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)
|
||||||
|
&& keyed::supported(&fact.selector)
|
||||||
|
{
|
||||||
|
let facet = fact.selector.clone();
|
||||||
|
detail = detail.push(
|
||||||
|
checkbox(self.presence.get(&facet).copied().unwrap_or(false))
|
||||||
|
.label(
|
||||||
|
"Include in destination collection (unchecked removes only this entry)",
|
||||||
|
)
|
||||||
|
.text_size(12)
|
||||||
|
.on_toggle(move |v| event(Event::Presence(facet.clone(), v))),
|
||||||
|
);
|
||||||
|
}
|
||||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||||
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
||||||
&& let Some(paths) = self
|
&& let Some(paths) = self
|
||||||
@@ -534,17 +603,24 @@ impl State {
|
|||||||
} else {
|
} else {
|
||||||
detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
|
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
|
let overlaps: Vec<_> = self
|
||||||
.decisions
|
.decisions
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|other| {
|
.filter(|other| {
|
||||||
**other != fact.selector
|
**other != fact.selector
|
||||||
&& (constructors::contains(&fact.selector, other)
|
&& (constructors::contains(&fact.selector, other)
|
||||||
|| constructors::contains(other, &fact.selector))
|
|| constructors::contains(other, &fact.selector)
|
||||||
|
|| keyed::overlap(other, &fact.selector))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() {
|
if self.decisions.contains_key(&fact.selector) && !overlaps.is_empty() {
|
||||||
detail = detail.push(text("Related decisions are selected. A first equal-kind acknowledgment can coexist with descendants; a replacing group requires one combined decision. Resolve overlaps explicitly; all drafts are retained.").size(11));
|
detail = detail.push(text("Related decisions are selected. Paired rows cannot review the same OA or SA identity twice. Replacing groups require one combined decision; first equal-kind acknowledgments may coexist with descendants. Resolve overlaps explicitly; all drafts are retained.").size(11));
|
||||||
for other in overlaps {
|
for other in overlaps {
|
||||||
detail = detail.push(action(
|
detail = detail.push(action(
|
||||||
format!("Leave {} unselected", title(project, other)),
|
format!("Leave {} unselected", title(project, other)),
|
||||||
@@ -557,6 +633,7 @@ impl State {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if (is_group
|
if (is_group
|
||||||
|
|| keyed::supported(&fact.selector)
|
||||||
|| constructors::selectors().iter().any(|group| {
|
|| constructors::selectors().iter().any(|group| {
|
||||||
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
|
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
|
||||||
}))
|
}))
|
||||||
@@ -595,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),
|
||||||
|
|||||||
@@ -225,3 +225,67 @@ scope protection. Both delegated core defects are resolved and exercised.
|
|||||||
Owned-file rustfmt check, strict Clippy (`--all-targets -- -D warnings`) and the
|
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
|
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.
|
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