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",