From ab661f2d332f540673ee0e2003b454c1765332bc Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 6 Sep 2026 04:08:46 +0300 Subject: [PATCH] Bound scenario value authoring to focused paged fields --- crates/oa-desktop/src/behavior/scenario.rs | 637 ++++++++++++++++++--- crates/oa-desktop/src/data_values.rs | 124 +++- 2 files changed, 680 insertions(+), 81 deletions(-) diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 8893405c..af4c81f3 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -80,7 +80,15 @@ pub enum Event { Binding(Id, Choice), Value(crate::data_values::FieldPathKey, String), ValueFocus(crate::data_values::FieldPathKey), + ValueBrowse(Option), + ValuePage(ValueSection, usize), } +#[derive(Debug, Clone, Copy)] +pub enum ValueSection { + Fields, + Authored, +} +const VALUE_PAGE_SIZE: usize = 24; #[derive(Default)] pub struct State { draft: Option, @@ -103,6 +111,9 @@ struct Draft { bindings: BTreeMap, values: BTreeMap, value_focus: Option, + value_browse: Option, + value_browse_page: usize, + value_authored_page: usize, } impl State { pub(super) fn pending_owner(&self) -> Option { @@ -149,6 +160,9 @@ impl State { bindings: BTreeMap::new(), values: BTreeMap::new(), value_focus: None, + value_browse: None, + value_browse_page: 0, + value_authored_page: 0, }); } Event::Cancel => self.acknowledge_commit(), @@ -166,8 +180,7 @@ impl State { Event::Exchange(c) => { if d.exchange != Some(c.id) { d.exchange = Some(c.id); - d.values.clear(); - d.value_focus = None; + d.reset_values(); d.sender = None; d.receiver = None; } @@ -207,8 +220,7 @@ impl State { }) = p.elements.get(&c.id).and_then(|e| e.shared()) { if d.exchange != *exchange { - d.values.clear(); - d.value_focus = None; + d.reset_values(); } d.exchange = *exchange; d.sender = *receiver; @@ -230,6 +242,21 @@ impl State { { 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 { let mut candidate = d.clone(); candidate.style = MessageStyle::Reply; candidate.call = Some(e.id); + if candidate.exchange != *exchange { + candidate.reset_values(); + } candidate.exchange = *exchange; candidate.sender = *receiver; candidate.receiver = *sender; @@ -401,6 +431,13 @@ fn calls(p: &Project, d: &Draft) -> Vec { .collect() } 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) { let kind = self.kind(); let exchange = self.exchange.and_then(|id| p.relations.get(&id)); @@ -416,21 +453,31 @@ impl Draft { }) { 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, p: &Project, exchange: &Relation, ) -> Result, String> { - let mut choices = crate::data_values::choices(p, exchange, &self.kind())?; - for key in self.values.keys().chain(self.value_focus.iter()) { - if !choices.iter().any(|choice| &choice.key == key) - && let Some(choice) = crate::data_values::resolve(p, exchange, &self.kind(), key) - { - choices.push(choice); - } + match &self.value_browse { + Some(key) => crate::data_values::children(p, exchange, &self.kind(), key), + None => Ok(crate::data_values::roots(p, exchange, &self.kind())), } - 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 { match self.style { @@ -670,6 +717,144 @@ impl Draft { fn event(e: Event) -> Message { Message::Behavior(super::Event::Scenario(e)) } +fn value_page(len: usize, requested: usize) -> std::ops::Range { + 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>( choices: Vec, selected: Option, @@ -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)) { - match d.value_choices(p, 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::, - |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)), - } + form = form.push(value_panel(p, d, exchange)); } if d.style == MessageStyle::CallReply { 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], field: f.leaf, }; + state + .update( + &f.project, + Event::ValueBrowse(crate::data_values::parent(&key)), + ) + .unwrap(); state .update(&f.project, Event::ValueFocus(key.clone())) .unwrap(); @@ -1446,7 +1578,7 @@ mod data_contract_tests { .draft .as_ref() .unwrap() - .value_choices(&f.project, &f.project.relations[&f.exchange]) + .browsed_values(&f.project, &f.project.relations[&f.exchange]) .unwrap() .iter() .any(|c| c.key == key) @@ -1490,4 +1622,361 @@ mod data_contract_tests { .unwrap(); 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()); + } } diff --git a/crates/oa-desktop/src/data_values.rs b/crates/oa-desktop/src/data_values.rs index 521d4159..12736cad 100644 --- a/crates/oa-desktop/src/data_values.rs +++ b/crates/oa-desktop/src/data_values.rs @@ -84,6 +84,35 @@ pub fn choices( .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 { + 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. /// In particular, optional recursive Value records may be traversed repeatedly. pub fn resolve( @@ -113,9 +142,11 @@ pub fn children( phase: &MessageKind, key: &FieldPathKey, ) -> Result, 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![]); - } + }; let Some(SharedBody::DataField { type_ref: TypeRef::Named(record), storage: FieldStorage::Value, @@ -137,14 +168,14 @@ pub fn children( path.push(key.field); Ok(data::effective_fields(project, *record) .into_iter() - .filter_map(|field| { - resolve( + .map(|field| { + named_choice( project, - exchange, - phase, - &FieldPathKey { + data::CarriedFieldPath { + item: entry.item, path: path.clone(), field: field.id, + direction: entry.direction, }, ) }) @@ -337,6 +368,85 @@ pub(crate) mod tests { validate_candidate(&f.project).unwrap(); (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] fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() { let (f, next) = recursive_payload();