Fix scoped semantic drafts and dependent review controls
This commit is contained in:
+150
-31
@@ -45,6 +45,7 @@ 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>,
|
||||||
port_contract_drafts: BTreeMap<String, Value>,
|
port_contract_drafts: BTreeMap<String, Value>,
|
||||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||||
}
|
}
|
||||||
@@ -83,6 +84,7 @@ impl Draft {
|
|||||||
value,
|
value,
|
||||||
inputs: BTreeMap::new(),
|
inputs: BTreeMap::new(),
|
||||||
exact_strings: false,
|
exact_strings: false,
|
||||||
|
context: None,
|
||||||
port_contract_drafts: BTreeMap::new(),
|
port_contract_drafts: BTreeMap::new(),
|
||||||
value_browsers: BTreeMap::new(),
|
value_browsers: BTreeMap::new(),
|
||||||
};
|
};
|
||||||
@@ -142,6 +144,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| {
|
||||||
@@ -226,6 +238,97 @@ 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(())
|
||||||
|
}
|
||||||
|
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,
|
||||||
@@ -233,36 +336,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(())
|
||||||
@@ -364,6 +438,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(),
|
||||||
@@ -578,7 +697,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")
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -513,6 +513,257 @@ fn field_page(
|
|||||||
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,7 +42,7 @@ 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(vec![]);
|
||||||
@@ -80,7 +80,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") {
|
||||||
|
|||||||
@@ -184,22 +184,28 @@ 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) => {
|
||||||
|
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||||
|
}
|
||||||
Event::Variant(path, value) => self.edit(project, &path, |d| {
|
Event::Variant(path, value) => self.edit(project, &path, |d| {
|
||||||
if d.materialize()?
|
if d.value
|
||||||
.pointer(&path)
|
.pointer(&path)
|
||||||
.is_some_and(|v| crate::forms::same_variant(v, &value))
|
.is_some_and(|v| crate::forms::same_variant(v, &value))
|
||||||
{
|
{
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
d.shape(&path, value)
|
d.shape_scoped(&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;
|
||||||
@@ -360,53 +366,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,10 +383,74 @@ impl State {
|
|||||||
Ok(request)
|
Ok(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compose selected scalar 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
|
||||||
|
.trace
|
||||||
|
.and_then(|id| project.traces.get(&id))
|
||||||
|
.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 draft = self.draft.as_ref().ok_or("Target draft is unavailable.")?;
|
||||||
|
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 => {
|
||||||
|
let value = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(_) if form_context => draft.value.clone(),
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
};
|
||||||
|
value
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
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 context = self.combined_value(project, true).ok();
|
||||||
|
if let Some(draft) = &mut self.draft {
|
||||||
|
draft.set_context(context);
|
||||||
|
}
|
||||||
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) else {
|
let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) 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."
|
||||||
|
|||||||
@@ -58,6 +58,210 @@ fn start(app: &mut App, source: Id) {
|
|||||||
fn facet(value: DataFacet) -> FacetSelector {
|
fn facet(value: DataFacet) -> FacetSelector {
|
||||||
FacetSelector::Data(value)
|
FacetSelector::Data(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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);
|
||||||
|
}
|
||||||
fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
|
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)));
|
||||||
|
|||||||
@@ -74,8 +74,12 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
|||||||
let keys = |keys: &KeyPair<Id>| {
|
let keys = |keys: &KeyPair<Id>| {
|
||||||
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(|| "Absent".into()),
|
||||||
|
keys.sa
|
||||||
|
.map(|id| choice(project, id).label)
|
||||||
|
.unwrap_or_else(|| "Absent".into())
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
match facet {
|
match facet {
|
||||||
@@ -93,12 +97,12 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
|
|||||||
"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)),
|
||||||
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
|
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
|
||||||
@@ -118,6 +122,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",
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ Base: `d130013`. Dedicated build target:
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
Verified against core `6bb2583`, using only the dedicated desktop target:
|
Verified after the independent scalar review fixes on SEM `6ad3cb`, using 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 +52,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 **277 desktop tests passed**, including **11 original semantic review tests**
|
||||||
|
and **5 scalar review regression 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,6 +66,25 @@ 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user