Bound scenario value authoring to focused paged fields
This commit is contained in:
@@ -80,7 +80,15 @@ pub enum Event {
|
|||||||
Binding(Id, Choice),
|
Binding(Id, Choice),
|
||||||
Value(crate::data_values::FieldPathKey, String),
|
Value(crate::data_values::FieldPathKey, String),
|
||||||
ValueFocus(crate::data_values::FieldPathKey),
|
ValueFocus(crate::data_values::FieldPathKey),
|
||||||
|
ValueBrowse(Option<crate::data_values::FieldPathKey>),
|
||||||
|
ValuePage(ValueSection, usize),
|
||||||
}
|
}
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum ValueSection {
|
||||||
|
Fields,
|
||||||
|
Authored,
|
||||||
|
}
|
||||||
|
const VALUE_PAGE_SIZE: usize = 24;
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct State {
|
pub struct State {
|
||||||
draft: Option<Draft>,
|
draft: Option<Draft>,
|
||||||
@@ -103,6 +111,9 @@ struct Draft {
|
|||||||
bindings: BTreeMap<Id, Id>,
|
bindings: BTreeMap<Id, Id>,
|
||||||
values: BTreeMap<crate::data_values::FieldPathKey, String>,
|
values: BTreeMap<crate::data_values::FieldPathKey, String>,
|
||||||
value_focus: Option<crate::data_values::FieldPathKey>,
|
value_focus: Option<crate::data_values::FieldPathKey>,
|
||||||
|
value_browse: Option<crate::data_values::FieldPathKey>,
|
||||||
|
value_browse_page: usize,
|
||||||
|
value_authored_page: usize,
|
||||||
}
|
}
|
||||||
impl State {
|
impl State {
|
||||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||||
@@ -149,6 +160,9 @@ impl State {
|
|||||||
bindings: BTreeMap::new(),
|
bindings: BTreeMap::new(),
|
||||||
values: BTreeMap::new(),
|
values: BTreeMap::new(),
|
||||||
value_focus: None,
|
value_focus: None,
|
||||||
|
value_browse: None,
|
||||||
|
value_browse_page: 0,
|
||||||
|
value_authored_page: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Event::Cancel => self.acknowledge_commit(),
|
Event::Cancel => self.acknowledge_commit(),
|
||||||
@@ -166,8 +180,7 @@ impl State {
|
|||||||
Event::Exchange(c) => {
|
Event::Exchange(c) => {
|
||||||
if d.exchange != Some(c.id) {
|
if d.exchange != Some(c.id) {
|
||||||
d.exchange = Some(c.id);
|
d.exchange = Some(c.id);
|
||||||
d.values.clear();
|
d.reset_values();
|
||||||
d.value_focus = None;
|
|
||||||
d.sender = None;
|
d.sender = None;
|
||||||
d.receiver = None;
|
d.receiver = None;
|
||||||
}
|
}
|
||||||
@@ -207,8 +220,7 @@ impl State {
|
|||||||
}) = p.elements.get(&c.id).and_then(|e| e.shared())
|
}) = p.elements.get(&c.id).and_then(|e| e.shared())
|
||||||
{
|
{
|
||||||
if d.exchange != *exchange {
|
if d.exchange != *exchange {
|
||||||
d.values.clear();
|
d.reset_values();
|
||||||
d.value_focus = None;
|
|
||||||
}
|
}
|
||||||
d.exchange = *exchange;
|
d.exchange = *exchange;
|
||||||
d.sender = *receiver;
|
d.sender = *receiver;
|
||||||
@@ -230,6 +242,21 @@ impl State {
|
|||||||
{
|
{
|
||||||
d.value_focus = Some(key);
|
d.value_focus = Some(key);
|
||||||
}
|
}
|
||||||
|
Event::ValueBrowse(key) => {
|
||||||
|
if key.as_ref().is_none_or(|key| {
|
||||||
|
d.exchange
|
||||||
|
.and_then(|id| p.relations.get(&id))
|
||||||
|
.and_then(|r| crate::data_values::resolve(p, r, &d.kind(), key))
|
||||||
|
.is_some()
|
||||||
|
}) {
|
||||||
|
d.value_browse = key;
|
||||||
|
d.value_browse_page = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::ValuePage(section, page) => match section {
|
||||||
|
ValueSection::Fields => d.value_browse_page = page,
|
||||||
|
ValueSection::Authored => d.value_authored_page = page,
|
||||||
|
},
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,6 +416,9 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
|||||||
let mut candidate = d.clone();
|
let mut candidate = d.clone();
|
||||||
candidate.style = MessageStyle::Reply;
|
candidate.style = MessageStyle::Reply;
|
||||||
candidate.call = Some(e.id);
|
candidate.call = Some(e.id);
|
||||||
|
if candidate.exchange != *exchange {
|
||||||
|
candidate.reset_values();
|
||||||
|
}
|
||||||
candidate.exchange = *exchange;
|
candidate.exchange = *exchange;
|
||||||
candidate.sender = *receiver;
|
candidate.sender = *receiver;
|
||||||
candidate.receiver = *sender;
|
candidate.receiver = *sender;
|
||||||
@@ -401,6 +431,13 @@ fn calls(p: &Project, d: &Draft) -> Vec<Choice> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
impl Draft {
|
impl Draft {
|
||||||
|
fn reset_values(&mut self) {
|
||||||
|
self.values.clear();
|
||||||
|
self.value_focus = None;
|
||||||
|
self.value_browse = None;
|
||||||
|
self.value_browse_page = 0;
|
||||||
|
self.value_authored_page = 0;
|
||||||
|
}
|
||||||
fn retain_value_paths(&mut self, p: &Project) {
|
fn retain_value_paths(&mut self, p: &Project) {
|
||||||
let kind = self.kind();
|
let kind = self.kind();
|
||||||
let exchange = self.exchange.and_then(|id| p.relations.get(&id));
|
let exchange = self.exchange.and_then(|id| p.relations.get(&id));
|
||||||
@@ -416,21 +453,31 @@ impl Draft {
|
|||||||
}) {
|
}) {
|
||||||
self.value_focus = None;
|
self.value_focus = None;
|
||||||
}
|
}
|
||||||
|
if self.value_browse.as_ref().is_some_and(|key| {
|
||||||
|
exchange
|
||||||
|
.and_then(|r| crate::data_values::resolve(p, r, &kind, key))
|
||||||
|
.is_none()
|
||||||
|
}) {
|
||||||
|
self.value_browse = None;
|
||||||
|
self.value_browse_page = 0;
|
||||||
}
|
}
|
||||||
fn value_choices(
|
}
|
||||||
|
fn browsed_values(
|
||||||
&self,
|
&self,
|
||||||
p: &Project,
|
p: &Project,
|
||||||
exchange: &Relation,
|
exchange: &Relation,
|
||||||
) -> Result<Vec<crate::data_values::FieldPathChoice>, String> {
|
) -> Result<Vec<crate::data_values::FieldPathChoice>, String> {
|
||||||
let mut choices = crate::data_values::choices(p, exchange, &self.kind())?;
|
match &self.value_browse {
|
||||||
for key in self.values.keys().chain(self.value_focus.iter()) {
|
Some(key) => crate::data_values::children(p, exchange, &self.kind(), key),
|
||||||
if !choices.iter().any(|choice| &choice.key == key)
|
None => Ok(crate::data_values::roots(p, exchange, &self.kind())),
|
||||||
&& let Some(choice) = crate::data_values::resolve(p, exchange, &self.kind(), key)
|
|
||||||
{
|
|
||||||
choices.push(choice);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(choices)
|
fn authored_values(&self) -> Vec<(&crate::data_values::FieldPathKey, &str)> {
|
||||||
|
self.values
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, value)| !value.trim().is_empty())
|
||||||
|
.map(|(key, value)| (key, value.as_str()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
fn kind(&self) -> MessageKind {
|
fn kind(&self) -> MessageKind {
|
||||||
match self.style {
|
match self.style {
|
||||||
@@ -670,6 +717,144 @@ impl Draft {
|
|||||||
fn event(e: Event) -> Message {
|
fn event(e: Event) -> Message {
|
||||||
Message::Behavior(super::Event::Scenario(e))
|
Message::Behavior(super::Event::Scenario(e))
|
||||||
}
|
}
|
||||||
|
fn value_page(len: usize, requested: usize) -> std::ops::Range<usize> {
|
||||||
|
let start = requested.min(len.saturating_sub(1) / VALUE_PAGE_SIZE) * VALUE_PAGE_SIZE;
|
||||||
|
start..(start + VALUE_PAGE_SIZE).min(len)
|
||||||
|
}
|
||||||
|
fn value_pager(len: usize, requested: usize, section: ValueSection) -> Element<'static, Message> {
|
||||||
|
let range = value_page(len, requested);
|
||||||
|
let page = range.start / VALUE_PAGE_SIZE;
|
||||||
|
row![
|
||||||
|
button("Previous")
|
||||||
|
.on_press_maybe((page > 0).then(|| event(Event::ValuePage(section, page - 1)))),
|
||||||
|
text(format!(
|
||||||
|
"Page {} of {} · {} fields",
|
||||||
|
page + 1,
|
||||||
|
len.div_ceil(VALUE_PAGE_SIZE).max(1),
|
||||||
|
len
|
||||||
|
))
|
||||||
|
.size(11),
|
||||||
|
button("Next")
|
||||||
|
.on_press_maybe((range.end < len).then(|| event(Event::ValuePage(section, page + 1))))
|
||||||
|
]
|
||||||
|
.spacing(6)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
fn value_panel<'a>(p: &Project, d: &'a Draft, exchange: &Relation) -> Element<'a, Message> {
|
||||||
|
use crate::data_values;
|
||||||
|
let mut out = column![text("Message values").size(14)].spacing(7);
|
||||||
|
if let Some(browse) = &d.value_browse {
|
||||||
|
let title = data_values::resolve(p, exchange, &d.kind(), browse)
|
||||||
|
.map(|choice| format!("Fields inside {}", choice.label))
|
||||||
|
.unwrap_or_else(|| "This record path is no longer available in this message.".into());
|
||||||
|
out = out.push(text(title).size(11)).push(
|
||||||
|
row![
|
||||||
|
button("Root fields").on_press(event(Event::ValueBrowse(None))),
|
||||||
|
button("Up one level")
|
||||||
|
.on_press(event(Event::ValueBrowse(data_values::parent(browse))))
|
||||||
|
]
|
||||||
|
.spacing(6),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
out = out.push(text("Root fields · choose a record to browse its fields").size(11));
|
||||||
|
}
|
||||||
|
match d.browsed_values(p, exchange) {
|
||||||
|
Ok(choices) if !choices.is_empty() => {
|
||||||
|
let range = value_page(choices.len(), d.value_browse_page);
|
||||||
|
let visible = choices[range].to_vec();
|
||||||
|
let selected = visible
|
||||||
|
.iter()
|
||||||
|
.find(|choice| Some(&choice.key) == d.value_focus.as_ref())
|
||||||
|
.cloned();
|
||||||
|
out = out
|
||||||
|
.push(value_pager(
|
||||||
|
choices.len(),
|
||||||
|
d.value_browse_page,
|
||||||
|
ValueSection::Fields,
|
||||||
|
))
|
||||||
|
.push(
|
||||||
|
pick_list(visible, selected, |choice| {
|
||||||
|
event(Event::ValueFocus(choice.key))
|
||||||
|
})
|
||||||
|
.placeholder("Choose a field on this page…")
|
||||||
|
.text_size(12)
|
||||||
|
.padding(8)
|
||||||
|
.width(Fill),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
out = out.push(text("No fields at this level for this message phase.").size(11));
|
||||||
|
}
|
||||||
|
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||||
|
}
|
||||||
|
match d
|
||||||
|
.value_focus
|
||||||
|
.as_ref()
|
||||||
|
.map(|key| data_values::resolve(p, exchange, &d.kind(), key))
|
||||||
|
{
|
||||||
|
Some(Some(focus)) => {
|
||||||
|
let key = focus.key;
|
||||||
|
out = out
|
||||||
|
.push(text(focus.label).size(12))
|
||||||
|
.push(
|
||||||
|
text_input(
|
||||||
|
"Optional value expression",
|
||||||
|
d.values.get(&key).map(String::as_str).unwrap_or(""),
|
||||||
|
)
|
||||||
|
.on_input({
|
||||||
|
let key = key.clone();
|
||||||
|
move |value| event(Event::Value(key.clone(), value))
|
||||||
|
})
|
||||||
|
.padding(8)
|
||||||
|
.size(12),
|
||||||
|
)
|
||||||
|
.push(text("An empty expression omits this value.").size(11));
|
||||||
|
let mut actions = row![
|
||||||
|
button("Show enclosing fields")
|
||||||
|
.on_press(event(Event::ValueBrowse(data_values::parent(&key))))
|
||||||
|
]
|
||||||
|
.spacing(6);
|
||||||
|
if matches!(p.elements.get(&key.field).and_then(oa_core::Element::shared),
|
||||||
|
Some(SharedBody::DataField { type_ref: TypeRef::Named(record),
|
||||||
|
storage: FieldStorage::Value, .. }) if data::is_record(p, *record))
|
||||||
|
{
|
||||||
|
actions = actions.push(
|
||||||
|
button("Browse record fields").on_press(event(Event::ValueBrowse(Some(key)))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out = out.push(actions);
|
||||||
|
}
|
||||||
|
Some(None) => {
|
||||||
|
out = out.push(text("The selected path is unavailable in this message. Choose a compatible field; entered values remain in the draft.")
|
||||||
|
.size(11).color(style::MUTED));
|
||||||
|
}
|
||||||
|
None => out = out.push(text("Choose a field to enter its value expression.").size(11)),
|
||||||
|
}
|
||||||
|
let authored = d.authored_values();
|
||||||
|
out = out.push(text(format!("Authored values ({})", authored.len())).size(12));
|
||||||
|
if authored.is_empty() {
|
||||||
|
out = out.push(text("No value expressions entered.").size(11));
|
||||||
|
} else {
|
||||||
|
out = out.push(value_pager(
|
||||||
|
authored.len(),
|
||||||
|
d.value_authored_page,
|
||||||
|
ValueSection::Authored,
|
||||||
|
));
|
||||||
|
for (key, expression) in &authored[value_page(authored.len(), d.value_authored_page)] {
|
||||||
|
let title = data_values::resolve(p, exchange, &d.kind(), key)
|
||||||
|
.map(|choice| choice.label)
|
||||||
|
.unwrap_or_else(|| format!("Unavailable field {} · review this value", key.field));
|
||||||
|
out = out.push(
|
||||||
|
button(
|
||||||
|
column![text(title).size(11), text(short(expression, 100)).size(11)].spacing(3),
|
||||||
|
)
|
||||||
|
.on_press(event(Event::ValueFocus((*key).clone())))
|
||||||
|
.width(Fill),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.into()
|
||||||
|
}
|
||||||
fn picker<'a>(
|
fn picker<'a>(
|
||||||
choices: Vec<Choice>,
|
choices: Vec<Choice>,
|
||||||
selected: Option<Id>,
|
selected: Option<Id>,
|
||||||
@@ -822,66 +1007,7 @@ pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Elemen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) {
|
if let Some(exchange) = d.exchange.and_then(|id| p.relations.get(&id)) {
|
||||||
match d.value_choices(p, exchange) {
|
form = form.push(value_panel(p, d, exchange));
|
||||||
Ok(choices) => {
|
|
||||||
let focus = d.value_focus.as_ref().and_then(|key| {
|
|
||||||
crate::data_values::resolve(p, exchange, &d.kind(), key)
|
|
||||||
});
|
|
||||||
form = form.push(
|
|
||||||
pick_list(choices.clone(), focus.clone(), |choice| {
|
|
||||||
event(Event::ValueFocus(choice.key))
|
|
||||||
})
|
|
||||||
.placeholder("Explore a record or choose a value path…")
|
|
||||||
.text_size(12)
|
|
||||||
.padding(8)
|
|
||||||
.width(Fill),
|
|
||||||
);
|
|
||||||
if let Some(focus) = focus {
|
|
||||||
if let Some(parent) = crate::data_values::parent(&focus.key) {
|
|
||||||
form = form.push(
|
|
||||||
button(text("Up to enclosing field").size(11))
|
|
||||||
.on_press(event(Event::ValueFocus(parent))),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
match crate::data_values::children(p, exchange, &d.kind(), &focus.key) {
|
|
||||||
Ok(children) if !children.is_empty() => {
|
|
||||||
form = form.push(
|
|
||||||
pick_list(
|
|
||||||
children,
|
|
||||||
None::<crate::data_values::FieldPathChoice>,
|
|
||||||
|choice| event(Event::ValueFocus(choice.key)),
|
|
||||||
)
|
|
||||||
.placeholder("Choose a field inside this record…")
|
|
||||||
.text_size(12)
|
|
||||||
.padding(8)
|
|
||||||
.width(Fill),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
form = form.push(text(error).size(11).color(style::MUTED))
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for choice in choices {
|
|
||||||
let key = choice.key;
|
|
||||||
form = form.push(
|
|
||||||
column![
|
|
||||||
text(choice.label).size(11),
|
|
||||||
text_input(
|
|
||||||
"Optional value expression",
|
|
||||||
d.values.get(&key).map(String::as_str).unwrap_or("")
|
|
||||||
)
|
|
||||||
.on_input(move |s| event(Event::Value(key.clone(), s)))
|
|
||||||
.padding(8)
|
|
||||||
.size(12)
|
|
||||||
]
|
|
||||||
.spacing(4),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => form = form.push(text(error).size(12).color(style::MUTED)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if d.style == MessageStyle::CallReply {
|
if d.style == MessageStyle::CallReply {
|
||||||
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation."));
|
||||||
@@ -1438,6 +1564,12 @@ mod data_contract_tests {
|
|||||||
path: vec![f.origin, next],
|
path: vec![f.origin, next],
|
||||||
field: f.leaf,
|
field: f.leaf,
|
||||||
};
|
};
|
||||||
|
state
|
||||||
|
.update(
|
||||||
|
&f.project,
|
||||||
|
Event::ValueBrowse(crate::data_values::parent(&key)),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
state
|
state
|
||||||
.update(&f.project, Event::ValueFocus(key.clone()))
|
.update(&f.project, Event::ValueFocus(key.clone()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1446,7 +1578,7 @@ mod data_contract_tests {
|
|||||||
.draft
|
.draft
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.value_choices(&f.project, &f.project.relations[&f.exchange])
|
.browsed_values(&f.project, &f.project.relations[&f.exchange])
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|c| c.key == key)
|
.any(|c| c.key == key)
|
||||||
@@ -1490,4 +1622,361 @@ mod data_contract_tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(state.draft.as_ref().unwrap().values[&out], "reply value");
|
assert_eq!(state.draft.as_ref().unwrap().values[&out], "reply value");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn panel_counts(p: &Project, d: &Draft, exchange: Id) -> (usize, usize) {
|
||||||
|
let input: Element<'_, Message> = text_input("", "").into();
|
||||||
|
let input_tag = iced::advanced::widget::Tree::new(input.as_widget()).tag;
|
||||||
|
let panel = value_panel(p, d, &p.relations[&exchange]);
|
||||||
|
let tree = iced::advanced::widget::Tree::new(panel.as_widget());
|
||||||
|
fn count(
|
||||||
|
tree: &iced::advanced::widget::Tree,
|
||||||
|
tag: iced::advanced::widget::tree::Tag,
|
||||||
|
) -> (usize, usize) {
|
||||||
|
tree.children.iter().fold(
|
||||||
|
(1, usize::from(tree.tag == tag)),
|
||||||
|
|(nodes, inputs), child| {
|
||||||
|
let (child_nodes, child_inputs) = count(child, tag);
|
||||||
|
(nodes + child_nodes, inputs + child_inputs)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
count(&tree, input_tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn large_payload_has_one_editor_with_reachable_field_and_authored_pages() {
|
||||||
|
let f = crate::data_values::tests::wide_payload();
|
||||||
|
let p = &f.project;
|
||||||
|
let exchange = &p.relations[&f.exchange];
|
||||||
|
let mut state = State::default();
|
||||||
|
state
|
||||||
|
.update(p, Event::Start(f.scenario, Tool::Message))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Exchange(named_choice(p, f.exchange)))
|
||||||
|
.unwrap();
|
||||||
|
let roots = crate::data_values::roots(p, exchange, &MessageKind::Call);
|
||||||
|
assert_eq!(roots.len(), 96);
|
||||||
|
assert_eq!(
|
||||||
|
panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
let mut visited = BTreeSet::new();
|
||||||
|
for page in 0..roots.len().div_ceil(VALUE_PAGE_SIZE) {
|
||||||
|
state
|
||||||
|
.update(p, Event::ValuePage(ValueSection::Fields, page))
|
||||||
|
.unwrap();
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
let choices = d.browsed_values(p, exchange).unwrap();
|
||||||
|
let visible = choices[value_page(choices.len(), d.value_browse_page)].to_vec();
|
||||||
|
assert!(visible.len() <= VALUE_PAGE_SIZE);
|
||||||
|
for choice in visible {
|
||||||
|
visited.insert(choice.key.clone());
|
||||||
|
state
|
||||||
|
.update(p, Event::ValueFocus(choice.key.clone()))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Value(choice.key, format!("draft {page}")))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let (nodes, inputs) = panel_counts(p, state.draft.as_ref().unwrap(), f.exchange);
|
||||||
|
assert_eq!(
|
||||||
|
inputs, 1,
|
||||||
|
"one input for over twelve thousand carried paths"
|
||||||
|
);
|
||||||
|
assert!(nodes < 200, "{nodes} widgets for the bounded value panel");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
visited,
|
||||||
|
roots.iter().map(|choice| choice.key.clone()).collect()
|
||||||
|
);
|
||||||
|
|
||||||
|
state
|
||||||
|
.update(p, Event::ValueBrowse(Some(roots[0].key.clone())))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(state.draft.as_ref().unwrap().value_browse_page, 0);
|
||||||
|
let children = state
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.browsed_values(p, exchange)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(children.len(), 128);
|
||||||
|
let mut visited_children = BTreeSet::new();
|
||||||
|
for page in 0..children.len().div_ceil(VALUE_PAGE_SIZE) {
|
||||||
|
state
|
||||||
|
.update(p, Event::ValuePage(ValueSection::Fields, page))
|
||||||
|
.unwrap();
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
let choices = d.browsed_values(p, exchange).unwrap();
|
||||||
|
for choice in &choices[value_page(choices.len(), d.value_browse_page)] {
|
||||||
|
assert_eq!(choice.key.path, [roots[0].key.field]);
|
||||||
|
visited_children.insert(choice.key.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
visited_children,
|
||||||
|
children.iter().map(|choice| choice.key.clone()).collect()
|
||||||
|
);
|
||||||
|
|
||||||
|
let nested = children[0].key.clone();
|
||||||
|
state.update(p, Event::ValueFocus(nested.clone())).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Value(nested.clone(), "unfinished(\"".into()))
|
||||||
|
.unwrap();
|
||||||
|
let before = state.draft.as_ref().unwrap().values.clone();
|
||||||
|
let before_browse = state.draft.as_ref().unwrap().value_browse.clone();
|
||||||
|
let before_page = state.draft.as_ref().unwrap().value_browse_page;
|
||||||
|
for event in [
|
||||||
|
Event::Style(MessageStyle::Call),
|
||||||
|
Event::Exchange(named_choice(p, f.exchange)),
|
||||||
|
Event::Style(MessageStyle::CallReply),
|
||||||
|
] {
|
||||||
|
state.update(p, event).unwrap();
|
||||||
|
}
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
assert_eq!(d.values, before);
|
||||||
|
assert_eq!(d.value_browse, before_browse);
|
||||||
|
assert_eq!(d.value_browse_page, before_page);
|
||||||
|
assert_eq!(d.value_focus, Some(nested));
|
||||||
|
|
||||||
|
let mut authored = BTreeSet::new();
|
||||||
|
for page in 0..before.len().div_ceil(VALUE_PAGE_SIZE) {
|
||||||
|
state
|
||||||
|
.update(p, Event::ValuePage(ValueSection::Authored, page))
|
||||||
|
.unwrap();
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
let values = d.authored_values();
|
||||||
|
for (key, _) in &values[value_page(values.len(), d.value_authored_page)] {
|
||||||
|
authored.insert((*key).clone());
|
||||||
|
}
|
||||||
|
let (nodes, inputs) = panel_counts(p, d, f.exchange);
|
||||||
|
assert_eq!(inputs, 1);
|
||||||
|
assert!(nodes < 200, "{nodes} widget nodes");
|
||||||
|
}
|
||||||
|
assert_eq!(authored, before.keys().cloned().collect());
|
||||||
|
state.update(p, Event::ValueBrowse(None)).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::ValuePage(ValueSection::Fields, usize::MAX))
|
||||||
|
.unwrap();
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
d.values, before,
|
||||||
|
"browsing and page clamping never remove drafts"
|
||||||
|
);
|
||||||
|
let OccurrenceBody::Exchange { values, .. } = d.message_body() else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
values.len(),
|
||||||
|
before.len(),
|
||||||
|
"commit includes every authored page"
|
||||||
|
);
|
||||||
|
assert_eq!(panel_counts(p, d, f.exchange).1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recursive_browsing_up_and_root_keep_values_and_report_the_depth_limit() {
|
||||||
|
let (f, next) = crate::data_values::tests::recursive_payload();
|
||||||
|
let p = &f.project;
|
||||||
|
let exchange = &p.relations[&f.exchange];
|
||||||
|
let mut state = State::default();
|
||||||
|
state
|
||||||
|
.update(p, Event::Start(f.scenario, Tool::Message))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Exchange(named_choice(p, f.exchange)))
|
||||||
|
.unwrap();
|
||||||
|
let root = crate::data_values::FieldPathKey {
|
||||||
|
path: vec![],
|
||||||
|
field: f.origin,
|
||||||
|
};
|
||||||
|
state.update(p, Event::ValueFocus(root.clone())).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::ValueBrowse(Some(root.clone())))
|
||||||
|
.unwrap();
|
||||||
|
let inner = state
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.browsed_values(p, exchange)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|choice| choice.key.field == next)
|
||||||
|
.unwrap()
|
||||||
|
.key;
|
||||||
|
state.update(p, Event::ValueFocus(inner.clone())).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::ValueBrowse(Some(inner.clone())))
|
||||||
|
.unwrap();
|
||||||
|
let leaf = state
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.browsed_values(p, exchange)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|choice| choice.key.field == f.leaf)
|
||||||
|
.unwrap()
|
||||||
|
.key;
|
||||||
|
assert_eq!(leaf.path, [f.origin, next]);
|
||||||
|
state.update(p, Event::ValueFocus(leaf.clone())).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Value(leaf.clone(), "keep this draft".into()))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::ValueBrowse(crate::data_values::parent(&inner)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(state.draft.as_ref().unwrap().value_browse, Some(root));
|
||||||
|
state.update(p, Event::ValueBrowse(None)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.draft.as_ref().unwrap().values[&leaf],
|
||||||
|
"keep this draft"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
let deepest = crate::data_values::FieldPathKey {
|
||||||
|
path: std::iter::once(f.origin)
|
||||||
|
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
|
||||||
|
.collect(),
|
||||||
|
field: next,
|
||||||
|
};
|
||||||
|
state.update(p, Event::ValueBrowse(Some(deepest))).unwrap();
|
||||||
|
assert!(
|
||||||
|
state
|
||||||
|
.draft
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.browsed_values(p, exchange)
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("128")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
panel_counts(p, state.draft.as_ref().unwrap(), f.exchange).1,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.draft.as_ref().unwrap().values[&leaf],
|
||||||
|
"keep this draft"
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.update(p, Event::Value(leaf.clone(), String::new()))
|
||||||
|
.unwrap();
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
assert!(d.authored_values().is_empty());
|
||||||
|
assert_eq!(d.values[&leaf], "", "empty input remains an editable draft");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reply_call_picker_previews_the_same_value_reset_as_selecting_another_exchange() {
|
||||||
|
let mut f = crate::data_values::tests::payload();
|
||||||
|
let p = &mut f.project;
|
||||||
|
let source = p.relations[&f.exchange].source();
|
||||||
|
let target = p.relations[&f.exchange].target();
|
||||||
|
let mut lifelines = vec![];
|
||||||
|
for (name, represented) in [("Caller", source), ("Callee", target)] {
|
||||||
|
let id = p
|
||||||
|
.add_element(Kind::Lifeline, Layer::Oa, name, Some(f.scenario))
|
||||||
|
.unwrap();
|
||||||
|
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||||
|
represented: Some(represented),
|
||||||
|
};
|
||||||
|
lifelines.push(id);
|
||||||
|
}
|
||||||
|
let other_item = p
|
||||||
|
.add_element(Kind::ExchangeItem, Layer::Oa, "Other operation", None)
|
||||||
|
.unwrap();
|
||||||
|
*p.elements
|
||||||
|
.get_mut(&other_item)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = p.elements[&f.item].shared().unwrap().clone();
|
||||||
|
let other_field = p
|
||||||
|
.add_element(Kind::DataField, Layer::Oa, "Result", Some(other_item))
|
||||||
|
.unwrap();
|
||||||
|
*p.elements
|
||||||
|
.get_mut(&other_field)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = SharedBody::DataField {
|
||||||
|
type_ref: TypeRef::Real,
|
||||||
|
direction: ParameterDirection::Out,
|
||||||
|
minimum: 1,
|
||||||
|
maximum: Some(1),
|
||||||
|
unit: String::new(),
|
||||||
|
storage: FieldStorage::Value,
|
||||||
|
default: None,
|
||||||
|
};
|
||||||
|
let other_exchange = p.relate(RelationKind::Interaction, "Other exchange", source, target);
|
||||||
|
p.relations.get_mut(&other_exchange).unwrap().body = RelationBody::Interaction {
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
items: vec![other_item],
|
||||||
|
};
|
||||||
|
let mut call_ids = vec![];
|
||||||
|
for (name, exchange) in [("First call", f.exchange), ("Other call", other_exchange)] {
|
||||||
|
let call = occurrence(
|
||||||
|
p,
|
||||||
|
f.scenario,
|
||||||
|
name,
|
||||||
|
OccurrenceBody::Exchange {
|
||||||
|
exchange: Some(exchange),
|
||||||
|
sender: Some(lifelines[0]),
|
||||||
|
receiver: Some(lifelines[1]),
|
||||||
|
message_kind: MessageKind::Call,
|
||||||
|
reply_to: None,
|
||||||
|
values: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
call_ids.push(call.id);
|
||||||
|
*p = execute_batch(p, &[Command::CreateElement(call)]).unwrap();
|
||||||
|
}
|
||||||
|
let mut state = State::default();
|
||||||
|
state
|
||||||
|
.update(p, Event::Start(f.scenario, Tool::Message))
|
||||||
|
.unwrap();
|
||||||
|
state.update(p, Event::Style(MessageStyle::Reply)).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Call(named_choice(p, call_ids[0])))
|
||||||
|
.unwrap();
|
||||||
|
let key = crate::data_values::FieldPathKey {
|
||||||
|
path: vec![f.destination],
|
||||||
|
field: f.leaf,
|
||||||
|
};
|
||||||
|
state.update(p, Event::ValueFocus(key.clone())).unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::Value(key.clone(), "reply expression".into()))
|
||||||
|
.unwrap();
|
||||||
|
state
|
||||||
|
.update(p, Event::ValueBrowse(crate::data_values::parent(&key)))
|
||||||
|
.unwrap();
|
||||||
|
let offered = calls(p, state.draft.as_ref().unwrap());
|
||||||
|
assert!(
|
||||||
|
offered.iter().any(|choice| choice.id == call_ids[1]),
|
||||||
|
"a different exchange remains selectable after entering a value"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.draft.as_ref().unwrap().values[&key],
|
||||||
|
"reply expression",
|
||||||
|
"preview is read-only"
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.update(p, Event::Call(named_choice(p, call_ids[0])))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.draft.as_ref().unwrap().values[&key],
|
||||||
|
"reply expression"
|
||||||
|
);
|
||||||
|
state
|
||||||
|
.update(p, Event::Call(named_choice(p, call_ids[1])))
|
||||||
|
.unwrap();
|
||||||
|
let d = state.draft.as_ref().unwrap();
|
||||||
|
assert!(d.values.is_empty());
|
||||||
|
assert!(d.value_focus.is_none());
|
||||||
|
assert!(d.value_browse.is_none());
|
||||||
|
assert_eq!(d.exchange, Some(other_exchange));
|
||||||
|
assert!(state.prepare_pending(p).is_ok());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,35 @@ pub fn choices(
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||||
|
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||||
|
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||||
|
let mut seen = std::collections::BTreeSet::new();
|
||||||
|
exchange
|
||||||
|
.body
|
||||||
|
.items()
|
||||||
|
.iter()
|
||||||
|
.flat_map(|item| {
|
||||||
|
data::effective_fields(project, *item)
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|field| {
|
||||||
|
let SharedBody::DataField { direction, .. } = field.shared()? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(data::CarriedFieldPath {
|
||||||
|
item: *item,
|
||||||
|
path: vec![],
|
||||||
|
field: field.id,
|
||||||
|
direction: *direction,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.filter(|entry| seen.insert(entry.field))
|
||||||
|
.filter(|entry| phase_allows(project, entry, phase))
|
||||||
|
.map(|entry| named_choice(project, entry))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||||
/// In particular, optional recursive Value records may be traversed repeatedly.
|
/// In particular, optional recursive Value records may be traversed repeatedly.
|
||||||
pub fn resolve(
|
pub fn resolve(
|
||||||
@@ -113,9 +142,11 @@ pub fn children(
|
|||||||
phase: &MessageKind,
|
phase: &MessageKind,
|
||||||
key: &FieldPathKey,
|
key: &FieldPathKey,
|
||||||
) -> Result<Vec<FieldPathChoice>, String> {
|
) -> Result<Vec<FieldPathChoice>, String> {
|
||||||
if resolve(project, exchange, phase, key).is_none() {
|
let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field)
|
||||||
|
.filter(|entry| phase_allows(project, entry, phase))
|
||||||
|
else {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
};
|
||||||
let Some(SharedBody::DataField {
|
let Some(SharedBody::DataField {
|
||||||
type_ref: TypeRef::Named(record),
|
type_ref: TypeRef::Named(record),
|
||||||
storage: FieldStorage::Value,
|
storage: FieldStorage::Value,
|
||||||
@@ -137,14 +168,14 @@ pub fn children(
|
|||||||
path.push(key.field);
|
path.push(key.field);
|
||||||
Ok(data::effective_fields(project, *record)
|
Ok(data::effective_fields(project, *record)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|field| {
|
.map(|field| {
|
||||||
resolve(
|
named_choice(
|
||||||
project,
|
project,
|
||||||
exchange,
|
data::CarriedFieldPath {
|
||||||
phase,
|
item: entry.item,
|
||||||
&FieldPathKey {
|
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
field: field.id,
|
field: field.id,
|
||||||
|
direction: entry.direction,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -337,6 +368,85 @@ pub(crate) mod tests {
|
|||||||
validate_candidate(&f.project).unwrap();
|
validate_candidate(&f.project).unwrap();
|
||||||
(f, next)
|
(f, next)
|
||||||
}
|
}
|
||||||
|
pub fn wide_payload() -> Payload {
|
||||||
|
let mut f = payload();
|
||||||
|
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||||
|
for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] {
|
||||||
|
for index in 0..count {
|
||||||
|
let body = f.project.elements[&template].shared().unwrap().clone();
|
||||||
|
let id = f
|
||||||
|
.project
|
||||||
|
.add_element(
|
||||||
|
Kind::DataField,
|
||||||
|
Layer::Oa,
|
||||||
|
&format!("Field {template} {index}"),
|
||||||
|
Some(owner),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
*f.project
|
||||||
|
.elements
|
||||||
|
.get_mut(&id)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() {
|
||||||
|
let mut f = wide_payload();
|
||||||
|
let exchange = &f.project.relations[&f.exchange];
|
||||||
|
let direct = roots(&f.project, exchange, &MessageKind::Call);
|
||||||
|
assert_eq!(direct.len(), 96);
|
||||||
|
assert!(direct.iter().all(|choice| choice.key.path.is_empty()));
|
||||||
|
let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap();
|
||||||
|
assert_eq!(representative.len(), 96 * 129);
|
||||||
|
for root in &direct {
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&f.project, exchange, &MessageKind::Call, &root.key),
|
||||||
|
Some(root.clone())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap();
|
||||||
|
assert_eq!(nested.len(), 128);
|
||||||
|
for child in &nested {
|
||||||
|
assert_eq!(
|
||||||
|
resolve(&f.project, exchange, &MessageKind::Call, &child.key),
|
||||||
|
Some(child.clone())
|
||||||
|
);
|
||||||
|
assert!(child.label.ends_with(" · In"));
|
||||||
|
}
|
||||||
|
// A temporarily over-limit model still exposes its unrelated root fields;
|
||||||
|
// the editor must not expand the whole graph before showing a choice.
|
||||||
|
for index in 0..32 {
|
||||||
|
let body = f.project.elements[&f.origin].shared().unwrap().clone();
|
||||||
|
let id = f
|
||||||
|
.project
|
||||||
|
.add_element(
|
||||||
|
Kind::DataField,
|
||||||
|
Layer::Oa,
|
||||||
|
&format!("Extra {index}"),
|
||||||
|
Some(f.item),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
*f.project
|
||||||
|
.elements
|
||||||
|
.get_mut(&id)
|
||||||
|
.unwrap()
|
||||||
|
.shared_mut()
|
||||||
|
.unwrap() = body;
|
||||||
|
}
|
||||||
|
let exchange = &f.project.relations[&f.exchange];
|
||||||
|
assert!(choices(&f.project, exchange, &MessageKind::Call).is_err());
|
||||||
|
assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128);
|
||||||
|
assert_eq!(
|
||||||
|
children(&f.project, exchange, &MessageKind::Call, &direct[0].key)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
128
|
||||||
|
);
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
|
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
|
||||||
let (f, next) = recursive_payload();
|
let (f, next) = recursive_payload();
|
||||||
|
|||||||
Reference in New Issue
Block a user