Fix scoped semantic drafts and dependent review controls

This commit is contained in:
2026-09-06 06:27:25 +03:00
parent d114a5d40c
commit e7fa061e23
7 changed files with 730 additions and 99 deletions
+150 -31
View File
@@ -45,6 +45,7 @@ pub struct Draft {
baseline: Value,
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
context: Option<Value>,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
@@ -83,6 +84,7 @@ impl Draft {
value,
inputs: BTreeMap::new(),
exact_strings: false,
context: None,
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
@@ -142,6 +144,16 @@ impl Draft {
pub fn materialize(&self) -> Result<Value, String> {
self.materialize_inputs(|_| true)
}
/// Read-only context for dependent controls. Workflow proposals never replace
/// the authored value or its input buffers and are not used by command().
pub fn context_value(&self) -> Value {
self.context
.clone()
.unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone()))
}
pub fn set_context(&mut self, context: Option<Value>) {
self.context = context;
}
/// Materialize selected scalar subtrees without parsing retained, unselected drafts.
pub fn materialize_scopes(&self, scopes: &[String]) -> Result<Value, String> {
self.materialize_inputs(|path| {
@@ -226,6 +238,97 @@ impl Draft {
self.rebuild();
Ok(())
}
/// Replace a workflow-authorized subtree without parsing unrelated inputs,
/// including an invalid value which this replacement deliberately removes.
pub fn shape_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
*self
.value
.pointer_mut(path)
.ok_or("Property no longer exists.")? = value;
self.rebuild_retaining(|old| (!within(old, path)).then(|| old.to_owned()));
Ok(())
}
fn rebuild_retaining(&mut self, new_path: impl Fn(&str) -> Option<String>) {
let previous = std::mem::take(&mut self.inputs);
self.rebuild();
for (path, input) in previous {
if let Some(path) = new_path(&path)
&& self.inputs.contains_key(&path)
{
self.inputs.insert(path, input);
}
}
}
pub fn array_scoped(
&mut self,
path: &str,
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
let mut candidate = self.value.clone();
let count = candidate
.pointer(path)
.and_then(Value::as_array)
.ok_or("List no longer exists.")?
.len();
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild_retaining(|old| {
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
return Some(old.to_owned());
};
let (number, rest) = tail.split_once('/').unwrap_or((tail, ""));
let position = number.parse::<usize>().ok()?;
let position = match index {
Some(index) if direction == 0 && position == index => return None,
Some(index) if direction == 0 && position > index => position - 1,
Some(index) if direction != 0 => {
let target = (index as i32 + direction).clamp(0, count as i32 - 1) as usize;
if position == index {
target
} else if position == target {
index
} else {
position
}
}
_ => position,
};
Some(if rest.is_empty() {
format!("{path}/{position}")
} else {
format!("{path}/{position}/{rest}")
})
});
Ok(())
}
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
self.value
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.rebuild_retaining(|path| Some(path.to_owned()));
Ok(())
}
pub fn value_path_scoped(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let row = self
.value
.pointer_mut(path)
.and_then(Value::as_object_mut)
.ok_or("Message value no longer exists.")?;
row.insert("path".into(), json!(key.path));
row.insert("field".into(), json!(key.field));
self.rebuild_retaining(|old| {
(!within(old, &format!("{path}/path")) && !within(old, &format!("{path}/field")))
.then(|| old.to_owned())
});
Ok(())
}
pub fn array(
&mut self,
path: &str,
@@ -233,36 +336,7 @@ impl Draft {
direction: i32,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let items = candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?;
match index {
None => items.push(if path.ends_with("literals") {
json!("New value")
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
json!({"x": 0.0, "y": 0.0})
} else if path.ends_with("port_anchors") {
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
} else {
json!(Id::nil())
}),
Some(i) if i < items.len() => {
if direction == 0 {
items.remove(i);
} else {
let target = (i as i32 + direction).clamp(0, items.len() as i32 - 1) as usize;
items.swap(i, target);
}
}
_ => return Err("List item no longer exists.".into()),
}
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild();
Ok(())
@@ -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 {
match name {
"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> {
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let value = draft.context_value();
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
let owner = value
.get("element")
+256 -5
View File
@@ -3,7 +3,7 @@ use super::*;
use iced::widget::column;
fn element(draft: &Draft, project: &Project) -> Option<Element> {
serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone()))
serde_json::from_value(draft.context_value())
.ok()
.or_else(|| project.elements.get(&draft.original).cloned())
}
@@ -113,9 +113,9 @@ pub(super) fn property<'a>(
let minimum_path = path.replace("maximum", "minimum");
json!(
draft
.materialize()
.ok()
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
.context_value()
.pointer(&minimum_path)
.and_then(Value::as_u64)
.unwrap_or(1)
.max(1)
)
@@ -311,7 +311,7 @@ fn message_value<'a>(
value: &'a Value,
path: &str,
) -> View<'a, Message> {
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let edited = draft.context_value();
let row = edited.pointer(path).unwrap_or(value).clone();
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
let owner = element(draft, project);
@@ -513,6 +513,257 @@ fn field_page(
mod tests {
use super::*;
fn semantic_state(app: &crate::app::App) -> &crate::semantic_review::State {
let Some(crate::workflow::Workflow::Semantic(state)) = &app.workflow else {
panic!()
};
state
}
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
use crate::semantic_review::Event;
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
semantic_send(
app,
Event::Decide(FacetSelector::Field(facet), Some(decision)),
);
}
fn default_buttons(draft: &Draft, project: &Project, path: &str) -> usize {
use iced::advanced::widget::{Tree, tree::Tag};
let button: View<'_, Message> = button(text("Button")).into();
let tag = Tree::new(button.as_widget()).tag;
let view = property(draft, project, draft.value.pointer(path).unwrap(), path).unwrap();
fn count(tree: &Tree, tag: Tag) -> usize {
usize::from(tree.tag == tag)
+ tree
.children
.iter()
.map(|child| count(child, tag))
.sum::<usize>()
}
count(&Tree::new(view.as_widget()), tag)
}
#[test]
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
use crate::semantic_review::{Event, State};
let mut p = Project::empty();
let oa = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
.unwrap();
let sa = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Target", None)
.unwrap();
let operation = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Operation base", None)
.unwrap();
let flow = p
.add_element(Kind::ExchangeItem, Layer::Sa, "Flow base", None)
.unwrap();
for (id, next) in [
(oa, ExchangeMechanism::Operation),
(sa, ExchangeMechanism::Flow),
(operation, ExchangeMechanism::Operation),
(flow, ExchangeMechanism::Flow),
] {
let Some(SharedBody::ExchangeItem { mechanism, .. }) =
p.elements.get_mut(&id).unwrap().shared_mut()
else {
panic!()
};
*mechanism = next;
}
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "item_trace".into(),
name: "Review item".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
let mut state = State::new(&p, oa).unwrap();
let facet = FacetSelector::ExchangeItem(ExchangeItemFacet::Mechanism);
let path = "/body/Sa/Shared/ExchangeItem/supertype";
let raw = state.draft.as_ref().unwrap().value.clone();
state.update(
&p,
Event::Decide(facet.clone(), Some(SemanticDecision::TakeSource)),
);
assert!(state.can_apply(), "{:?}", state.error);
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
assert!(choices.contains(&operation));
assert!(!choices.contains(&flow));
assert_eq!(state.draft.as_ref().unwrap().value, raw);
state.update(&p, Event::Decide(facet, Some(SemanticDecision::KeepTarget)));
let choices = reference_ids(state.draft.as_ref().unwrap(), &p, path);
assert!(!choices.contains(&operation));
assert!(choices.contains(&flow));
}
#[test]
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
use crate::semantic_review::Event;
for reverse in [false, true] {
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
let mut p = Project::empty();
let mut pair = vec![];
for layer in [Layer::Oa, Layer::Sa] {
let source = (layer == Layer::Oa) != reverse;
let record = p
.add_element(Kind::DataType, layer, "Record", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() =
SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let field = p
.add_element(Kind::DataField, layer, "Payload", Some(record))
.unwrap();
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() =
SharedBody::DataField {
type_ref: if source {
TypeRef::Boolean
} else {
TypeRef::Integer
},
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
pair.push(field);
}
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "payload_trace".into(),
name: "Payload review".into(),
description: String::new(),
oa: pair[0],
sa: pair[1],
kind: TraceKind::Manual,
baseline: None,
},
);
let (source, target, layer) = if reverse {
(pair[1], pair[0], "Oa")
} else {
(pair[0], pair[1], "Sa")
};
let root = format!("/body/{layer}/Shared/DataField");
let default = format!("{root}/default");
let type_path = format!("{root}/type_ref");
let minimum = format!("{root}/minimum");
let mut app = crate::app::App::with_project(p);
let before = app.document.snapshot();
let _ = app.update(Message::StartSemanticReview(source));
// An unfinished candidate must not make the context fall back to
// the old target type, or contaminate a later Keep decision.
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::Candidate);
semantic_send(&mut app, Event::Input(minimum.clone(), "-".into()));
semantic_decide(&mut app, FieldFacet::Type, decision);
if decision == SemanticDecision::Candidate {
semantic_send(
&mut app,
Event::Variant(type_path.clone(), json!("Boolean")),
);
}
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
assert!(!semantic_state(&app).can_apply());
let draft = semantic_state(&app).draft.as_ref().unwrap();
assert!(matches!(
element(draft, app.project()).unwrap().shared(),
Some(SharedBody::DataField {
type_ref: TypeRef::Boolean,
..
})
));
assert_eq!(draft.inputs[&minimum].raw, "-");
semantic_decide(&mut app, FieldFacet::Minimum, SemanticDecision::KeepTarget);
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
assert!(semantic_state(&app).can_apply());
let draft = semantic_state(&app).draft.as_ref().unwrap();
assert_eq!(default_buttons(draft, app.project(), &default), 2);
// These are the same element/definition/literal helpers used by
// the visible Set literal default and Add literal buttons.
let owner = element(draft, app.project()).unwrap();
let Some(SharedBody::DataField { type_ref, .. }) = owner.shared() else {
panic!()
};
let initial = literal(&definition(app.project(), type_ref).unwrap()).unwrap();
assert_eq!(initial, json!({"Boolean": false}));
if decision == SemanticDecision::TakeSource {
assert_eq!(draft.value.pointer(&type_path), Some(&json!("Integer")));
}
// Reverting the type changes the controls without changing its
// retained candidate. Storage constraints likewise read context.
semantic_decide(&mut app, FieldFacet::Type, SemanticDecision::KeepTarget);
assert!(matches!(
element(semantic_state(&app).draft.as_ref().unwrap(), app.project())
.unwrap()
.shared(),
Some(SharedBody::DataField {
type_ref: TypeRef::Integer,
..
})
));
semantic_decide(&mut app, FieldFacet::Type, decision);
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::Candidate);
semantic_send(
&mut app,
Event::Variant(format!("{root}/storage"), json!("Reference")),
);
assert_eq!(
default_buttons(
semantic_state(&app).draft.as_ref().unwrap(),
app.project(),
&default
),
0
);
semantic_decide(&mut app, FieldFacet::Storage, SemanticDecision::KeepTarget);
assert_eq!(
default_buttons(
semantic_state(&app).draft.as_ref().unwrap(),
app.project(),
&default
),
2
);
semantic_decide(&mut app, FieldFacet::Default, SemanticDecision::Candidate);
semantic_send(&mut app, Event::Shape(default, json!([initial])));
assert!(
semantic_state(&app).can_apply(),
"{:?}",
semantic_state(&app).error
);
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
assert!(
matches!(app.project().elements[&target].shared(), Some(SharedBody::DataField {
type_ref: TypeRef::Boolean, default: Some(values), minimum: 0, storage: FieldStorage::Value, ..
}) if values == &vec![DataValue::Boolean(false)])
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
}
}
}
#[test]
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
let (mut f, next) = crate::data_values::tests::recursive_payload();
+2 -2
View File
@@ -42,7 +42,7 @@ pub(super) fn variants(
_value: &Value,
) -> Option<Vec<(String, Value)>> {
if path.ends_with("/Port/contract") {
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let current = draft.context_value();
let sa = current.pointer("/body/Sa").is_some();
if !sa {
return Some(vec![]);
@@ -80,7 +80,7 @@ pub(super) fn variants(
);
}
if path.ends_with("/Port/contract/Component/orientation") {
let current = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let current = draft.context_value();
let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind";
let orientations: &[&str] =
if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") {
+77 -53
View File
@@ -184,22 +184,28 @@ impl State {
d.edit_text(&path, action);
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| {
if d.materialize()?
if d.value
.pointer(&path)
.is_some_and(|v| crate::forms::same_variant(v, &value))
{
Ok(())
} else {
d.shape(&path, value)
d.shape_scoped(&path, value)
}
}),
Event::Array(path, index, direction) => {
self.edit(project, &path, |d| d.array(&path, index, direction))
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
}
Event::Append(path, value) => {
self.edit(project, &path, |d| d.append_scoped(&path, value))
}
Event::ValuePath(path, key) => {
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
}
Event::Append(path, value) => self.edit(project, &path, |d| d.append(&path, value)),
Event::ValuePath(path, key) => self.edit(project, &path, |d| d.value_path(&path, key)),
Event::Tab(tab) => {
self.tab = tab;
replan = false;
@@ -360,53 +366,7 @@ impl State {
.values()
.any(|d| *d == SemanticDecision::Candidate)
{
let original =
Draft::from_project(project, target).ok_or("Target has no editable record.")?;
let mut final_value = original.value;
let scopes: Vec<_> = self
.decisions
.iter()
.filter(|(_, decision)| **decision == SemanticDecision::Candidate)
.filter_map(|(facet, _)| scalar::binding(project, target, facet))
.collect();
let draft = self
.draft
.as_ref()
.ok_or("Target draft is unavailable.")?
.materialize_scopes(&scopes)?;
let mut map = BTreeMap::new();
for (oa, sa) in &self.references {
let (from, to) = if self.direction == TransitionDirection::OaToSa {
(*oa, *sa)
} else {
(*sa, *oa)
};
if map.insert(from, to).is_some_and(|old| old != to) {
return Err("Reverse reference correspondence is ambiguous.".into());
}
}
map.insert(source, target);
for (facet, decision) in &self.decisions {
if *decision == SemanticDecision::KeepTarget {
continue;
}
let path = scalar::binding(project, target, facet)
.ok_or("Selected property is not applicable to the target.")?;
let value = match decision {
SemanticDecision::KeepTarget => continue,
SemanticDecision::Candidate => draft
.pointer(&path)
.cloned()
.ok_or("Candidate property is no longer available.")?,
SemanticDecision::TakeSource => {
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
scalar::source_value(&fact, self.direction.source_layer(), &map)?
}
};
*final_value
.pointer_mut(&path)
.ok_or("Final target property is unavailable.")? = value;
}
let final_value = self.combined_value(project, false)?;
let candidate = if project.elements.contains_key(&target) {
SemanticRecord::Element(
serde_json::from_value(final_value)
@@ -423,10 +383,74 @@ impl State {
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) {
self.plan = None;
self.proposed = 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 {
self.error = Some(
"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 {
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) {
send(app, Event::Select(selected.clone()));
send(app, Event::Decide(selected, Some(decision)));
+18 -6
View File
@@ -74,8 +74,12 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
let keys = |keys: &KeyPair<Id>| {
format!(
"{} ↔ {}",
keys.oa.map(|id| project.named(id)).unwrap_or("Absent"),
keys.sa.map(|id| project.named(id)).unwrap_or("Absent")
keys.oa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into()),
keys.sa
.map(|id| choice(project, id).label)
.unwrap_or_else(|| "Absent".into())
)
};
match facet {
@@ -93,12 +97,12 @@ pub(super) fn title(project: &Project, facet: &FacetSelector) -> String {
"Message value · {} ↔ {}",
pair.oa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent"),
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into()),
pair.sa
.as_ref()
.map(|key| project.named(key.field))
.unwrap_or("Absent")
.map(|key| annotation_title(project, key))
.unwrap_or_else(|| "Absent".into())
),
FacetSelector::Binding(pair) => format!("Lifeline binding · {}", keys(pair)),
FacetSelector::Child(pair) => format!("Owned child · {}", keys(pair)),
@@ -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 {
match drift {
Drift::Unreviewed => "Unreviewed",
+23 -2
View File
@@ -42,7 +42,8 @@ Base: `d130013`. Dedicated build target:
## 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
$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
```
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
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
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:
`D:/CodexBuilds/Capella2-semantic-desktop-target/debug/arcadia-studio.exe`.
The build target is idle. No native application was launched by this task.