Compare commits

...
Author SHA1 Message Date
sorokin.oleg.edu 1a3164bf16 Record exchange continuation review fix and updated freeze
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:43:48 +03:00
sorokin.oleg.edu 8bc564b4b2 Fix exchange-only process continuation with explicit append path 2026-09-06 05:42:01 +03:00
sorokin.oleg.edu dfc14bcd1f Record direct process editing verification and native gates 2026-09-06 05:35:47 +03:00
sorokin.oleg.edu 7a1e12bc7c Add direct process and chain occurrence editing 2026-09-06 05:33:42 +03:00
sorokin.oleg.edu a306d6b2a8 Plan direct process and chain editing milestone 2026-09-06 05:12:38 +03:00
sorokin.oleg.edu 1bf8ebca96 Record combined DATA scenario verification and frozen Windows artifact
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
2026-09-06 05:04:32 +03:00
sorokin.oleg.edu dfd2e41259 Merge tested R6 data workflows into direct scenario workspace 2026-09-06 05:04:04 +03:00
sorokin.oleg.edu 05a84b217d Record final scenario save-scroll review and build evidence 2026-09-06 04:59:01 +03:00
sorokin.oleg.edu e7e37d485d Keep observed scenario scrolling current during async saves 2026-09-06 04:58:45 +03:00
sorokin.oleg.edu b35dede75f Browse message value properties by record with bounded pages 2026-09-06 04:56:41 +03:00
sorokin.oleg.edu 5fa9c905c8 Record verified scenario review fixes and fresh binary evidence 2026-09-06 04:54:22 +03:00
sorokin.oleg.edu 7a666076bf Fix scenario scroll cancellation, operand activations and picker cost 2026-09-06 04:53:23 +03:00
sorokin.oleg.edu f5467e50c5 Preserve authored source and reports through desktop migration 2026-09-06 04:43:41 +03:00
sorokin.oleg.edu 74989b6f25 Keep nested data field names visible in compact pickers 2026-09-06 04:40:08 +03:00
sorokin.oleg.edu 524dabb9c3 Record direct scenario checks and pending native acceptance 2026-09-06 04:33:36 +03:00
sorokin.oleg.edu d1d8f0bbed Add direct scenario gestures and atomic ordered message insertion 2026-09-06 04:33:13 +03:00
sorokin.oleg.edu 0edadeebdb Record R6 cross-platform tests and native data workflows 2026-09-06 04:29:29 +03:00
sorokin.oleg.edu 93e3137342 Plan direct scenario drawing and ordered insertion milestone 2026-09-06 04:14:22 +03:00
sorokin.oleg.edu ab661f2d33 Bound scenario value authoring to focused paged fields 2026-09-06 04:08:46 +03:00
sorokin.oleg.edu 15ed1ff3af Verify qualified scenario values through HTTP and record R6 evidence 2026-09-06 04:07:49 +03:00
sorokin.oleg.edu b403039d00 Add typed data contract editing and bounded dictionary views 2026-09-06 03:58:43 +03:00
sorokin.oleg.edu 7426c03f4e Explain R6 data authoring and record verification gaps 2026-09-06 03:58:12 +03:00
35 changed files with 10916 additions and 732 deletions
+3 -1
View File
@@ -53,6 +53,8 @@ The example opens in an architecture view with work nested inside its performers
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
@@ -70,7 +72,7 @@ In the state view, **Select / move** drags vertices and containers without repar
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
arcadia 3;
arcadia 4;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
+555 -5
View File
@@ -12,8 +12,219 @@ use oa_core::{
use serde_json::{Value, json};
use std::{collections::BTreeSet, path::PathBuf, sync::Arc};
#[cfg(test)]
mod direct_scenario_tests {
use super::*;
use crate::behavior::{
self,
scenario::{self, CanvasEvent, Event, MessageStyle, Slot},
};
fn setup() -> (App, scenario::direct_tests::Fixture) {
let f = scenario::direct_tests::fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.scenario_context().unwrap().scenario, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Scenario(event)));
}
fn begin(app: &App, f: &scenario::direct_tests::Fixture) -> CanvasEvent {
CanvasEvent::Begin {
context: app.scenario_context().unwrap(),
slot: Slot {
parent: f.owner,
before: None,
},
sender: f.sender,
receiver: f.receiver,
style: MessageStyle::Asynchronous,
}
}
#[test]
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let context = app.scenario_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.scenario_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.scenario_context(), Some(context));
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
let context = app.scenario_context().unwrap();
for (area, offset) in [
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
] {
let before = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
assert_ne!(app.scenario_context(), Some(before));
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
assert_eq!(
app.scenario_context(),
Some(current),
"unchanged redraw notification retains context"
);
}
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(&mut app, Event::Name("Keep my draft".into()));
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
iced::Vector::new(0., 200.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original);
let current = app.scenario_context().unwrap();
for dimension in 0..3 {
let mut stale = current;
match dimension {
0 => stale.document = Id::new_v4(),
1 => stale.scenario = Id::new_v4(),
_ => stale.revision += 1,
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(app.scenario_context(), Some(current));
}
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_rejects_every_stale_context_and_preserves_property_drafts() {
let (mut app, f) = setup();
let before = app.document.snapshot();
for dimension in 0..4 {
let mut event = begin(&app, &f);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match dimension {
0 => context.document = Id::new_v4(),
1 => context.scenario = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
}
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
let _ = app.update(Message::Field(
"/name".into(),
"Pending scenario name".into(),
));
assert!(app.draft_dirty());
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
#[test]
fn direct_scenario_creation_and_reorder_preserve_selection_and_each_undo_once() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
let created = app.selected;
assert_ne!(created, f.owner);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[f.call, f.reply, created]
);
let before_reorder = app.document.bundle().clone();
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Select(
context, f.sender,
)));
assert_eq!(
app.scenario_context(),
Some(context),
"same-owner selection retains gesture context"
);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Reorder {
context,
occurrence: created,
slot: Slot {
parent: f.owner,
before: Some(f.call),
},
}));
assert_eq!(app.selected, created);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[created, f.call, f.reply]
);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before_reorder);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &original);
assert_eq!(app.project(), &f.project);
assert!(!app.document.can_undo());
}
#[test]
fn direct_scenario_source_failure_and_navigation_keep_form_until_success() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 4000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app, &f);
let _ = app.update(Message::ScenarioCanvas(event));
form(
&mut app,
Event::Exchange(Choice {
id: f.exchange,
label: "Contract".into(),
}),
);
form(&mut app, Event::Name("Long message".repeat(1024)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered message".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_eq!(app.project().named(app.selected), "Recovered message");
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
}
}
#[cfg(test)]
mod behavior_lifecycle_tests;
mod process_canvas;
mod state_diagram;
#[cfg(test)]
mod state_diagram_tests;
@@ -115,6 +326,7 @@ pub enum Message {
BrowseGroup(crate::explorer::Group),
ExplorerPage(usize),
CreateKind(Kind),
Data(crate::data_view::Event),
Add(bool),
AddRelation,
PortAction(crate::port_workflow::Action),
@@ -127,6 +339,9 @@ pub enum Message {
Field(String, String),
FieldText(String, text_editor::Action),
FieldShape(String, Value),
FieldAppend(String, Value),
FieldValuePath(String, crate::data_values::FieldPathKey),
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
FieldVariant(String, Value),
FieldArray(String, Option<usize>, i32),
Apply,
@@ -144,6 +359,8 @@ pub enum Message {
BehaviorOwner(Choice),
BehaviorDiagram(Choice),
StateDiagram(crate::behavior::state_edit::Event),
ScenarioCanvas(crate::behavior::scenario::CanvasEvent),
TopologyCanvas(crate::behavior::topology::CanvasEvent),
PlaceSelected,
HideSelected,
ToggleCollapse,
@@ -204,6 +421,7 @@ pub struct App {
pub page: Page,
pub guide: crate::guide::State,
pub behavior: crate::behavior::State,
pub data_state: crate::data_view::State,
pub inspector_tab: crate::navigation::InspectorTab,
pub layer: Layer,
pub selected: Id,
@@ -292,6 +510,7 @@ impl App {
page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
data_state: Default::default(),
inspector_tab: Default::default(),
layer,
selected,
@@ -574,9 +793,14 @@ impl App {
}
fn execute(&mut self, command: Command) -> bool {
let command = crate::port_edit::repair_owner_change(self.project(), command);
let scenario_selection = self
.behavior
.scenario
.created_selection(&command)
.or_else(|| self.behavior.topology.created_selection(&command));
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = selected {
if let Some(id) = scenario_selection.or(selected) {
self.selected = id;
}
self.sync();
@@ -732,6 +956,19 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
// Scrollable geometry can change while async work or an overlay blocks
// commands. Keep the observed translation current; its passive handler
// still validates document, scenario and revision before accepting it.
if matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
| Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
)
) {
return self.handle(message);
}
// Global subscriptions also receive keys consumed by an overlay.
if self.model_menu.is_some() && matches!(message, Message::Escape) {
self.model_menu = None;
@@ -764,6 +1001,8 @@ impl App {
Message::Field(..)
| Message::FieldText(..)
| Message::FieldShape(..)
| Message::FieldAppend(..)
| Message::FieldValuePath(..)
| Message::FieldVariant(..)
| Message::FieldArray(..)
| Message::ElementKind(_)
@@ -887,6 +1126,11 @@ impl App {
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
let mut task = self.handle(message);
if let Some(context) = self.topology_context() {
self.behavior
.topology
.enter(context.document, context.owner);
}
if self.connection.as_ref().is_some_and(|session| {
Some(session.context) != self.route_context()
|| self.page != Page::Diagram
@@ -896,6 +1140,7 @@ impl App {
self.connection = None;
}
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
@@ -928,8 +1173,116 @@ impl App {
task
}
}
pub(crate) fn scenario_context(&self) -> Option<crate::behavior::scenario::Context> {
let scenario = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& self.project().elements.get(&scenario)?.kind() == Kind::Scenario)
.then_some(crate::behavior::scenario::Context {
document: self.canvas_document,
scenario,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Inner and outer scroll notifications may come from the
// same frame. Accept both across the first epoch bump,
// while still rejecting another owner/document/revision.
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.revision == context.revision
}) && self.behavior.scenario.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.scenario_context() != Some(context) {
return Task::none();
}
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id)
== Some(context.scenario)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.scenario.choose_tool(context.scenario, tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
"Apply or cancel pending edits before changing the scenario."
.into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
slot,
sender,
receiver,
style,
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_message(
self.project(),
context,
slot,
sender,
receiver,
style,
);
self.behavior.scenario = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the message exchange and apply at the highlighted sequence position.".into();
}
Err(error) => self.status = error,
}
}
CanvasEvent::Reorder {
occurrence, slot, ..
} => {
match scenario::reorder(
self.project(),
context.scenario,
occurrence,
slot,
) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.selected_node = None;
self.refresh_draft();
self.status = "Occurrence reordered within its sequence · Undo restores the previous order".into();
}
}
Err(error) => self.status = error,
}
}
_ => unreachable!(),
}
}
}
}
Message::TopologyCanvas(event) => return self.edit_process_canvas(event),
Message::StateDiagram(event) => return self.edit_state_diagram(event),
Message::BehaviorOwner(choice) => {
if self.apply()
@@ -1124,6 +1477,32 @@ impl App {
self.status = e;
}
}
Message::FieldAppend(path, value) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.append(&path, value)
{
self.status = error;
}
}
Message::FieldValueBrowse(path, scope, page) => {
if let Some(draft) = &mut self.draft
&& draft
.value
.pointer(&path)
.is_some_and(|row| row.get("expression").is_some())
{
draft
.value_browsers
.insert(path, crate::forms::ValueBrowser { scope, page });
}
}
Message::FieldValuePath(path, key) => {
if let Some(draft) = &mut self.draft
&& let Err(error) = draft.value_path(&path, key)
{
self.status = error;
}
}
Message::FieldVariant(path, value) => {
if let Some(d) = &mut self.draft {
let result = d.materialize().and_then(|current| {
@@ -1384,6 +1763,19 @@ impl App {
self.fit();
}
}
Message::Data(event) => {
if self.data_state.update(&event) {
return Task::none();
}
if self.apply() {
match crate::data_view::command(self.project(), self.layer, event) {
Ok(command) => {
self.execute(command);
}
Err(error) => self.status = error,
}
}
}
Message::CreateKind(kind) => self.create_kind = kind,
Message::Add(child) => {
if self.apply() {
@@ -1478,6 +1870,9 @@ impl App {
Ok(Some(command)) => {
if self.execute(command) {
self.behavior.acknowledge_commit();
if self.topology_context().is_some() {
return self.reveal_process_selection();
}
}
}
Ok(None) => {}
@@ -2203,7 +2598,16 @@ impl App {
Err(e) => e,
};
}
Message::WindowResized(size) => self.window_size = size,
Message::WindowResized(size) => {
// The canvas can retain its capped size during a real window
// resize. Invalidate any sequence pointer/slot gesture anyway.
if self.window_size != size
&& (self.scenario_context().is_some() || self.topology_context().is_some())
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.window_size = size;
}
Message::CanvasResized(size) => {
let first_measurement = self.canvas_size.is_none();
let size_changed = self.canvas_size != Some(size);
@@ -2577,8 +2981,14 @@ impl App {
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
self.status =
"Source project opened. Included files and comments are preserved.".into();
self.migration = self.document.compiled().decoded.migration.clone();
if self.migration.is_some() {
self.status = "Imported earlier source schema. Review Model checks; Save all explicitly writes the upgraded source while retaining comments and includes.".into();
self.page = Page::Validation;
} else {
self.status =
"Source project opened. Included files and comments are preserved.".into();
}
}
Err(error) => {
self.canvas_document = Id::new_v4();
@@ -3208,7 +3618,9 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
&& let Some(source_files::DiskExpectation::Bytes(bytes)) =
disk.expectations.get(&disk.bundle.entry)
&& let Ok(decoded) = persistence::decode_with_report_limited(bytes, LIMITS.model)
&& decoded.migration.is_some()
// Source1/2 require the historical single-document adapters.
// Source3+ must retain its authored bundle and token migration.
&& decoded.migration.as_ref().is_some_and(|report| report.from_schema <= 2)
{
return Ok(LoadedSnapshot::Legacy(
path,
@@ -3293,6 +3705,97 @@ mod source_project_tests {
Ok(())
}
#[test]
fn source3_native_open_preserves_report_comments_nesting_and_save_reopen() {
let original = include_str!("../../oa-core/tests/fixtures/v3-data-contract.arcadia");
for split in [false, true] {
let directory = Directory::new();
let path = directory.0.join("project.arcadia");
let mut authored = std::collections::BTreeMap::new();
if split {
let start = original.find("{\n").unwrap() + 2;
let end = original.rfind('}').unwrap();
authored.insert(
"project.arcadia",
format!(
"{} include \"dictionary.arcadia\";\n}}\n",
&original[..start]
),
);
authored.insert("dictionary.arcadia", original[start..end].to_owned());
} else {
authored.insert("project.arcadia", original.to_owned());
}
for (file, source) in &authored {
std::fs::write(directory.0.join(file), source).unwrap();
}
let mut app = App::with_project(Project::empty());
send(
&mut app,
Message::Loaded(Ok(Some(read_snapshot(path.clone()).unwrap()))),
);
assert_eq!(app.migration.as_ref().unwrap().from_schema, 3);
assert_eq!(app.page, Page::Validation);
assert_eq!(app.path, Some(path.clone()));
assert_eq!(app.document.bundle().files.len(), authored.len());
assert!(
app.dirty(),
"accepted source migration needs explicit saving"
);
for (file, source) in &authored {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let text = app
.document
.bundle()
.files
.values()
.cloned()
.collect::<String>();
assert!(text.contains("// Reused record parameters retain their own identities."));
assert!(text.contains("// Frozen authored schema3 compatibility input."));
let packet = app
.project()
.elements
.values()
.find(|e| e.alias == "packet")
.unwrap()
.id;
let origin = app
.project()
.elements
.values()
.find(|e| e.alias == "origin")
.unwrap()
.id;
assert_eq!(
app.document.compiled().index.lexical_owners.get(&origin),
Some(&packet)
);
let before = app.document.snapshot();
save(&mut app, path.clone()).unwrap();
assert!(!app.dirty());
for (file, source) in &before.bundle.files {
assert_eq!(
std::fs::read_to_string(directory.0.join(file)).unwrap(),
*source
);
}
let mut reopened = App::with_project(Project::empty());
send(
&mut reopened,
Message::Loaded(Ok(Some(read_snapshot(path).unwrap()))),
);
assert!(reopened.migration.is_none());
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.document.bundle(), app.document.bundle());
assert!(!reopened.dirty());
}
}
fn pending_journal(directory: &Directory, before: Option<&str>) -> (PathBuf, PathBuf) {
let path = directory.0.join("project.arcadia");
let journal = directory.0.join(".arcadia-save-project.arcadia.journal");
@@ -3847,3 +4350,50 @@ mod lint_tests {
assert!(app.project().elements.is_empty());
}
}
#[cfg(test)]
mod data_paging_lifecycle_tests {
use super::*;
#[test]
fn paging_preserves_same_context_and_resets_on_layer_or_document_replacement() {
let mut p = Project::empty();
let mut oa = None;
for layer in [Layer::Oa, Layer::Sa] {
for index in 0..70 {
let id = p
.add_element(
Kind::Interface,
layer,
&format!("Interface {layer} {index}"),
None,
)
.unwrap();
if layer == Layer::Oa {
oa = Some(id);
}
}
}
let mut app = App::with_project(p.clone());
let page = Message::Data(crate::data_view::Event::Page {
section: crate::data_view::Section::Catalog,
owner: None,
page: 2,
});
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(page.clone());
let retained = app.data_state.clone();
let _ = app.update(Message::Page(Page::Catalog));
let _ = app.update(Message::Page(Page::Data));
let _ = app.update(Message::Layer(Layer::Oa));
assert_eq!(app.data_state, retained);
let _ = app.update(Message::Layer(Layer::Sa));
assert_eq!(app.data_state, crate::data_view::State::default());
let _ = app.update(page.clone());
let _ = app.update(Message::Select(oa.unwrap()));
assert_eq!(app.layer, Layer::Oa);
assert_eq!(app.data_state, crate::data_view::State::default());
let _ = app.update(page);
app.reset(p, None);
assert_eq!(app.data_state, crate::data_view::State::default());
}
}
@@ -322,14 +322,18 @@ fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
Message::Field("/name".into(), "Unapplied state name".into()),
);
assert!(app.draft_dirty());
let context = behavior::topology::Context {
document: app.canvas_document,
owner: fixture.machine,
revision: app.document.revision(),
epoch: app.canvas_epoch,
};
for event in [
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
900., 600.,
))),
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
behavior::Event::Topology(behavior::topology::Event::Fit),
behavior::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
] {
send(app, Message::Behavior(event));
send(app, Message::TopologyCanvas(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
+591
View File
@@ -0,0 +1,591 @@
use super::*;
use crate::behavior::topology::{self, CanvasEvent};
impl App {
pub(crate) fn topology_context(&self) -> Option<topology::Context> {
let owner = crate::behavior::selected_owner(self)?;
(self.page == Page::Behavior
&& matches!(
self.project().elements.get(&owner)?.kind(),
Kind::Process | Kind::FunctionalChain
))
.then_some(topology::Context {
document: self.canvas_document,
owner,
revision: self.document.revision(),
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
if let CanvasEvent::Scrolled(_, area, offset) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.scrolled(area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Viewport(_, size) = event {
if self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
}) && self.behavior.topology.viewport(size)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
self.behavior
.topology
.enter(context.document, context.owner);
match event {
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
{
self.select(id);
self.selected_node = None;
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let changed = state.expand(self.project(), context.owner, id);
self.behavior.topology = state;
if changed {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
CanvasEvent::Open(_, occurrence) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
if let Some(target) =
topology::referenced(self.project(), context.owner, occurrence)
{
if self
.project()
.elements
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.select(target);
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.topology);
let back = state.back(self.project());
self.behavior.topology = state;
if let Some((owner, occurrence)) = back {
self.behavior.active_owner = Some(owner);
self.select(occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
mutation => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before changing the process.".into();
return Task::none();
}
match mutation {
CanvasEvent::Begin {
tool,
control,
insertion,
..
} => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.begin(self.project(), context, tool, control, insertion);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status="Choose the contextual contribution and apply at the captured position.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Describe the path condition, then Create.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reconnect {
link, end, target, ..
} => {
match topology::reconnect(self.project(), context.owner, link, end, target)
{
Ok(command) => {
if self.execute(command) {
self.selected = link;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
CanvasEvent::Reorder {
occurrence, before, ..
} => {
match topology::reorder(self.project(), context.owner, occurrence, before) {
Ok(command) => {
if self.execute(command) {
self.selected = occurrence;
self.refresh_draft();
return self.reveal_process_selection();
}
}
Err(e) => self.status = e,
}
}
_ => unreachable!(),
}
}
}
Task::none()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::behavior::{
self,
topology::{
CanvasTool, Event, Insertion, Tool,
direct_tests::{self, Fixture},
},
};
fn setup() -> (App, Fixture) {
let f = direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
assert_eq!(app.topology_context().unwrap().owner, f.owner);
(app, f)
}
fn form(app: &mut App, event: Event) {
let _ = app.update(Message::Behavior(behavior::Event::Topology(event)));
}
fn begin(app: &App) -> CanvasEvent {
CanvasEvent::Begin {
context: app.topology_context().unwrap(),
tool: Tool::Work,
control: ControlKind::Choice,
insertion: Insertion::default(),
}
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
for field in 0..4 {
let mut event = begin(&app);
let CanvasEvent::Begin { context, .. } = &mut event else {
unreachable!()
};
match field {
0 => context.document = Id::new_v4(),
1 => context.owner = Id::new_v4(),
2 => context.revision += 1,
_ => context.epoch += 1,
};
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
let _ = app.update(Message::Field("/name".into(), "Pending owner name".into()));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
assert!(!app.behavior.has_pending());
assert!(app.draft_dirty());
let context = app.topology_context().unwrap();
for event in [
CanvasEvent::Zoom(context, Some(0.7)),
CanvasEvent::Viewport(context, iced::Size::new(510., 360.)),
] {
let _ = app.update(Message::TopologyCanvas(event));
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[test]
fn direct_process_app_source_failure_retains_form_then_commits_once_and_selects_occurrence() {
let (mut app, f) = setup();
let bundle = app.document.bundle().clone();
let mut limits = oa_core::source_project::ProjectLimits::default();
limits.model.max_source_bytes =
bundle.files.values().map(String::len).sum::<usize>() + 3000;
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
app.sync();
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(
&mut app,
Event::Target(Choice {
id: f.work[0],
label: "Assess".into(),
}),
);
form(&mut app, Event::Name("Retain this long input".repeat(1000)));
let _ = app.update(Message::Page(Page::Catalog));
assert_eq!(app.page, Page::Behavior);
form(&mut app, Event::Submit);
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &bundle);
assert!(!app.document.can_undo());
form(&mut app, Event::Name("Recovered contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending());
assert_ne!(app.selected, f.owner);
assert_eq!(app.project().named(app.selected), "Recovered contribution");
let created = app.selected;
let after = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &bundle);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &after);
assert!(app.project().contains(created));
}
#[test]
fn direct_process_app_same_owner_selection_preserves_tool_but_owner_change_resets_it() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Connect,
)));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
context, f.nodes[0],
)));
assert_eq!(app.topology_context(), Some(context));
let other = app
.project()
.make_element(Kind::Process, Layer::Oa, "Other", None)
.unwrap();
let other_id = other.id;
assert!(app.execute(Command::CreateElement(other)));
let _ = app.update(Message::BehaviorOwner(Choice {
id: other_id,
label: "Other".into(),
}));
assert_eq!(app.topology_context().unwrap().owner, other_id);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Connect {
context,
source: f.nodes[0],
target: f.nodes[2],
}));
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
let mut stale = context;
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
document: context.document,
revision: context.revision,
snapshot: original.clone(),
path: root.join(&original.bundle.entry),
disk: Some(source_files::DiskProject {
root,
bundle: original.bundle.clone(),
expectations: Default::default(),
reachable: original.bundle.files.keys().cloned().collect(),
diagnostics: vec![],
}),
};
let _ = app.update(Message::Saved(Ok(Some(receipt))));
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
assert_eq!(app.topology_context(), Some(context));
let _ = app.update(Message::WindowResized(iced::Size::new(
size.width + 20.,
size.height,
)));
assert_ne!(app.topology_context(), Some(context));
let event = begin(&app);
let _ = app.update(Message::TopologyCanvas(event));
form(&mut app, Event::Name("Keep this form".into()));
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 80.),
)));
assert!(app.behavior.has_pending());
assert_eq!(app.document.bundle(), &original.bundle);
}
#[test]
fn direct_process_app_open_source_and_back_preserve_local_reference_identity() {
let (mut app, f) = setup();
let source = app
.project()
.make_element(Kind::Process, Layer::Oa, "Reusable source", None)
.unwrap();
let source_id = source.id;
assert!(app.execute(Command::CreateElement(source)));
let mut occurrence = app
.project()
.make_element(Kind::Occurrence, Layer::Oa, "Local use", Some(f.owner))
.unwrap();
let occurrence_id = occurrence.id;
if let Some(SharedBody::Occurrence { body, .. }) = occurrence.shared_mut() {
*body = OccurrenceBody::SequenceReference {
sequence: Some(source_id),
};
}
assert!(app.execute(Command::CreateElement(occurrence)));
let snapshot = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Open(
context,
occurrence_id,
)));
assert_eq!(app.selected, source_id);
assert_eq!(app.topology_context().unwrap().owner, source_id);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Back(context)));
assert_eq!(app.selected, occurrence_id);
assert_eq!(app.topology_context().unwrap().owner, f.owner);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
}
#[cfg(test)]
#[test]
fn direct_process_dirty_source_blocks_canvas_creation_without_losing_text() {
let f = topology::direct_tests::fixture(Layer::Oa);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let original = app.document.snapshot();
let edited = format!(
"{}\n// Retained source draft\n",
app.source_workspace.text()
);
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(edited.clone().into()),
)));
assert!(app.source_dirty());
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: topology::Tool::Work,
control: ControlKind::Choice,
insertion: topology::Insertion::default(),
}));
assert!(!app.behavior.has_pending());
assert!(app.source_dirty());
assert_eq!(app.source_workspace.text(), edited);
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+197 -413
View File
@@ -1,8 +1,16 @@
//! Process/chain authoring and projection use contextual occurrences and explicit
//! SequenceLinks. Presentation order never invents control-flow relationships.
use super::*;
use iced::widget::{Column, column, text_input};
use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
mod direct;
mod view;
use direct::Input;
pub(crate) use direct::{
CanvasEvent, CanvasTool, Context, Insertion, reconnect, referenced, reorder,
};
#[cfg(test)]
pub(crate) mod direct_tests;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tool {
@@ -40,9 +48,8 @@ pub enum Event {
Guard(String),
AlternateGuard(String),
Control(ControlChoice),
Viewport(Size),
Zoom(f32),
Fit,
Search(usize, String),
Page(usize, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlChoice(pub ControlKind);
@@ -59,11 +66,25 @@ impl std::fmt::Display for ControlChoice {
}
#[derive(Default)]
pub struct State {
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<(Id, Id)>,
draft: Option<Draft>,
viewport: Option<Size>,
zoom: Option<f32>,
fit: bool,
}
#[derive(Default)]
struct Browser {
search: String,
page: usize,
}
struct Draft {
insertion: Insertion,
fixed: bool,
browsers: [Browser; 4],
owner: Id,
tool: Tool,
name: String,
@@ -99,9 +120,6 @@ impl State {
return Err(PENDING_DRAFT.into());
}
match event {
Event::Viewport(size) => self.viewport = Some(size),
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
Event::Fit => self.zoom = None,
Event::Start(owner, tool, selected) => {
let e = p
.elements
@@ -127,6 +145,9 @@ impl State {
}
});
self.draft = Some(Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool,
name: String::new(),
@@ -154,6 +175,11 @@ impl State {
Event::Guard(value) => d.guard = value,
Event::AlternateGuard(value) => d.alternate_guard = value,
Event::Control(value) => d.control = value.0,
Event::Search(index, value) if index < 4 => {
d.browsers[index].search = value;
d.browsers[index].page = 0;
}
Event::Page(index, value) if index < 4 => d.browsers[index].page = value,
_ => {}
}
}
@@ -163,12 +189,13 @@ impl State {
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
self.zoom.unwrap_or_else(|| {
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
})
if !self.fit {
return self.zoom.unwrap_or(1.);
}
let width = self.viewport.map_or(fallback_width, |s| s.width);
((width - 24.).max(1.) / graph.width.max(1.))
.min(450. / graph.height.max(1.))
.clamp(0.05, 1.)
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
@@ -247,13 +274,24 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.name, e.alias),
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
})
.collect()
};
choices.sort_by(|a, b| a.label.cmp(&b.label));
choices
}
fn semantic_path(p: &Project, id: Id) -> String {
let mut path = Vec::new();
let mut current = Some(id);
while let Some(id) = current {
let Some(e) = p.elements.get(&id) else { break };
path.push(e.alias.as_str());
current = e.parent;
}
path.reverse();
path.join(" / ")
}
fn contributions(p: &Project, owner: Id) -> Vec<Choice> {
p.elements
.get(&owner)
@@ -394,6 +432,40 @@ impl Draft {
if self.before.is_some() && self.before == self.after {
return Err("Choose different preceding and following contributions.".into());
}
let captured = self
.insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, self.owner, r.source())
&& is_contribution(p, self.owner, r.target())
})
.ok_or("The captured path no longer exists in this process.")
})
.transpose()?;
if captured
.is_some_and(|r| Some(r.source()) != self.before || Some(r.target()) != self.after)
{
return Err("The captured path changed. Choose its position again.".into());
}
let matches: Vec<_> = sequence_links(p, self.owner)
.into_iter()
.filter(|r| Some(r.source()) == self.before && Some(r.target()) == self.after)
.collect();
let split_link = if self.tool == Tool::Connect {
None
} else if captured.is_some() {
captured
} else if !self.fixed && matches.len() == 1 {
Some(matches[0])
} else if !self.fixed && matches.len() > 1 {
return Err("Select the specific sequence link to insert on.".into());
} else {
None
};
let mut commands = preserved_order(
p,
self.owner,
@@ -418,7 +490,6 @@ impl Draft {
{
return Err("This path already follows the stored order. Convert the ordered path to edit its sequence link.".into());
}
require_guard(p, source, &self.guard)?;
commands.push(link(p, source, target, &self.guard));
return Ok(unique_batch(p, commands));
}
@@ -459,12 +530,6 @@ impl Draft {
return Err("Choose same-layer work for the second path.".into());
}
let parallel = self.control == ControlKind::Fork;
if !parallel && (self.guard.trim().is_empty() || self.alternate_guard.trim().is_empty())
{
return Err(
"Describe both alternative guards; use 'else' for the fallback path.".into(),
);
}
let split = occurrence(
p,
self.owner,
@@ -552,30 +617,47 @@ impl Draft {
commands.push(Command::CreateElement(e));
(id, id)
};
if let Some(before) = self.before {
// Branch guards belong to the split's two outgoing paths. A branch
// inserted after another choice must first use an explicit link.
let guard = if self.tool == Tool::Branch {
""
} else {
&self.guard
};
require_guard(p, before, guard)?;
commands.push(link(p, before, first, guard));
if let Some(existing) = split_link {
let mut updated = existing.clone();
if let RelationBody::SequenceLink { target, .. } = &mut updated.body {
*target = first;
}
commands.push(Command::EditRelation(existing.id, updated));
} else if let Some(before) = self.before {
commands.push(link(
p,
before,
first,
if self.tool == Tool::Branch {
""
} else {
&self.guard
},
));
}
if let Some(after) = self.after {
commands.push(link(p, last, after, ""));
}
// Inserting between two selected contributions replaces their direct
// precedence while preserving other links and their semantic identities.
if let (Some(before), Some(after)) = (self.before, self.after) {
commands.extend(
sequence_links(p, self.owner)
.into_iter()
.filter(|r| r.source() == before && r.target() == after)
.map(|r| Command::Delete(r.id)),
);
}
let new_ids: Vec<_> = commands
.iter()
.filter_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(self.owner) => Some(e.id),
_ => None,
})
.collect();
let mut order = owner.steps().expect("process steps").clone();
let slot = self.insertion.before.or(self.after);
let index = slot
.map(|id| {
order
.iter()
.position(|v| *v == id)
.ok_or("The presentation slot changed.")
})
.transpose()?
.unwrap_or(order.len());
order.splice(index..index, new_ids);
commands.push(Command::SetSteps(self.owner, order));
Ok(unique_batch(p, commands))
}
}
@@ -598,23 +680,6 @@ pub(super) fn unique_batch(p: &Project, mut commands: Vec<Command>) -> Command {
}
Command::Batch(commands)
}
fn require_guard(p: &Project, source: Id, guard: &str) -> Result<(), String> {
if guard.trim().is_empty()
&& matches!(
p.elements.get(&source).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Control {
kind: ControlKind::Choice | ControlKind::Loop
},
..
})
)
{
Err("Describe this choice / repeat condition, or enter 'else'.".into())
} else {
Ok(())
}
}
fn event(e: Event) -> Message {
Message::Behavior(if matches!(e, Event::Submit) {
super::Event::Submit
@@ -622,307 +687,9 @@ fn event(e: Event) -> Message {
super::Event::Topology(e)
})
}
fn picker<'a>(
choices: Vec<Choice>,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
pick_list(choices, selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(9)
.text_size(12)
.width(Fill)
.into()
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
view::view(app, owner, out)
}
pub fn view<'a>(app: &'a App, owner: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
let p = app.project();
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
if !p.elements[&owner].description.is_empty() {
out = out.push(
text(&p.elements[&owner].description)
.size(12)
.color(style::MUTED),
);
}
out = out
.push(
row![
action("+ Work", event(Event::Start(owner, Tool::Work, selected))),
action(
"+ Exchange",
event(Event::Start(owner, Tool::Exchange, selected))
),
action("Reuse…", event(Event::Start(owner, Tool::Reuse, selected))),
]
.spacing(7),
)
.push(
row![
action(
"+ Control",
event(Event::Start(owner, Tool::Control, selected))
),
action(
"Branch / parallel…",
event(Event::Start(owner, Tool::Branch, selected))
),
action(
"Connect…",
event(Event::Start(owner, Tool::Connect, selected))
),
action("Properties", Message::EditProperties(owner)),
]
.spacing(7),
);
if let Some(d) = app
.behavior
.topology
.draft
.as_ref()
.filter(|d| d.owner == owner)
{
let mut form = column![text(d.tool.title()).size(16)].spacing(9);
let legacy = legacy_order(p, owner);
if legacy.len() > 1 {
form = form.push(label(format!("This edit will preserve the {} ordered contributions as explicit sequence links in the same undoable operation. Inserting between two contributions replaces their direct link.", legacy.len())));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices: Vec<_> = targets(p, owner, d.tool)
.into_iter()
.filter(|choice| compatible_source(p, d, choice.id))
.collect();
if choices.is_empty() {
form = form.push(label("No compatible sources for these connections. Change the insertion points, or create the matching work, exchange or reusable process first."));
}
form = form.push(picker(
choices,
d.target,
"Choose an existing source…",
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source name)", &d.name)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(9)
.text_size(12)
.width(Fill),
);
}
if d.tool == Tool::Branch {
form = form.push(picker(
targets(p, owner, Tool::Work),
d.alternate,
"Work on the second path…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form.push(
row![
text_input("First path condition", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
text_input("Second path condition", &d.alternate_guard)
.on_input(|s| event(Event::AlternateGuard(s)))
.padding(9)
.size(12),
]
.spacing(7),
);
}
form = form.push(label("Creates split, two work contributions, merge and their links in one undoable operation."));
}
form = form
.push(
row![
picker(
contributions(p, owner),
d.before,
if d.tool == Tool::Connect {
"From contribution…"
} else {
"After contribution (optional)…"
},
Event::Before
),
action("Clear", event(Event::ClearBefore)),
]
.spacing(7),
)
.push(
row![
picker(
contributions(p, owner),
d.after,
if d.tool == Tool::Connect {
"To contribution…"
} else {
"Before contribution (optional)…"
},
Event::After
),
action("Clear", event(Event::ClearAfter)),
]
.spacing(7),
);
if d.tool != Tool::Branch {
form = form.push(
text_input("Sequence guard (for a choice or loop)", &d.guard)
.on_input(|s| event(Event::Guard(s)))
.padding(9)
.size(12),
);
}
if let Err(problem) = d.command(p) {
form = form.push(label(problem));
}
form = form.push(
row![
button("Create")
.padding(9)
.on_press_maybe(d.command(p).is_ok().then_some(event(Event::Submit))),
action("Cancel", event(Event::Cancel))
]
.spacing(7),
);
out = out.push(container(form).padding(14).style(style::card));
}
let (canvas, size): (Element<'a, Message>, Size) = if uses_explicit_topology(p, owner)
|| p.elements[&owner].steps().is_none_or(Vec::is_empty)
{
let mut graph = Graph::new(p, owner, app.selected);
if graph.nodes.is_empty() {
out = out.push(container(column![
text("Start this process").size(18),
label("Use + Work to involve an existing activity, or + Exchange for an interaction. Then connect contributions or insert alternative and parallel paths."),
].spacing(12)).padding(24).width(Fill).style(style::card));
return scrollable(out.padding(24)).into();
} else if graph.edges.is_empty() {
out = out.push(label(
"No explicit sequence links. Connect contributions to describe topology.",
));
}
graph.scale = app
.behavior
.topology
.scale(graph.size, (app.window_size.width - 600.).max(200.));
let scale = graph.scale;
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
out = out.push(
row![
action("Fit", event(Event::Fit)),
action("−", event(Event::Zoom(scale / 1.25))),
label(format!("{:.0}%", scale * 100.)),
action("+", event(Event::Zoom(scale * 1.25))),
action("100%", event(Event::Zoom(1.))),
]
.spacing(8)
.align_y(iced::Center),
);
(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height)
.into(),
size,
)
} else {
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
let conversion = preserved_order(p, owner, None);
out = out.push(label(
"Ordered exchange path · the stored contribution order defines this sequence.",
));
if !conversion.is_empty() {
out = out.push(action(
"Convert ordered path to explicit links",
Message::Command(unique_batch(p, conversion)),
));
}
(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
size,
)
};
out = out.push(
iced::widget::sensor(
container(
scrollable(canvas)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(size.height.clamp(200., 470.)),
)
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(|size| event(Event::Viewport(size)))
.on_resize(|size| event(Event::Viewport(size))),
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference {
sequence: Some(target),
},
..
}) = p.elements.get(&app.selected).and_then(|e| e.shared())
{
out = out.push(action(
&format!("Open source: {} →", p.named(*target)),
Message::Select(*target),
));
}
for r in sequence_links(p, owner) {
let guard = match &r.body {
RelationBody::SequenceLink { guard, .. } if !guard.is_empty() => format!(" [{guard}]"),
_ => String::new(),
};
out = out.push(
action(
&format!("{} → {}{}", p.named(r.source()), p.named(r.target()), guard),
Message::Select(r.id),
)
.width(Fill),
);
}
scrollable(out.padding(24)).into()
}
fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
p.relations
.values()
@@ -942,6 +709,7 @@ struct GraphNode {
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, Id)>,
preview_total: usize,
}
struct GraphEdge {
id: Id,
@@ -951,15 +719,21 @@ struct GraphEdge {
points: Vec<Point>,
label: Rectangle,
}
struct Graph {
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
size: Size,
selected: Id,
scale: f32,
}
impl Graph {
fn new(p: &Project, owner: Id, selected: Id) -> Self {
impl<'a> Graph<'a> {
fn new(p: &'a Project, owner: Id, selected: Id) -> Self {
Self::expanded(p, owner, selected, &BTreeSet::new())
}
fn expanded(p: &'a Project, owner: Id, selected: Id, expanded: &BTreeSet<Id>) -> Self {
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
@@ -1000,17 +774,17 @@ impl Graph {
fallback += 1;
}
}
let mut rows: BTreeMap<usize, usize> = BTreeMap::new();
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut nodes = vec![];
let mut size: Size = Size::new(640., 220.);
for c in choices {
let e = &p.elements[&c.id];
let rank = ranks[&c.id];
let slot = rows.entry(rank).or_default();
let slot = rows.entry(rank).or_insert(24.);
let mut node = GraphNode {
id: e.id,
rect: Rectangle::new(
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
Point::new(24. + rank as f32 * 420., *slot),
Size::new(270., 104.),
),
title: e.name.clone(),
@@ -1019,8 +793,8 @@ impl Graph {
source: None,
preview: vec![],
preview_links: vec![],
preview_total: 0,
};
*slot += 1;
if let Some(SharedBody::Occurrence { body, .. }) = e.shared() {
match body {
OccurrenceBody::Action { action, .. } => {
@@ -1051,11 +825,19 @@ impl Graph {
.map(|id| format!("ref · {}", p.named(id)))
.unwrap_or("Choose source in Properties".into());
if let Some(source) = sequence {
node.preview = contributions(p, *source)
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
.into_iter()
.take(4)
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.map(|c| (c.id, p.named(c.id).to_owned()))
.collect();
node.rect.height =
(132. + node.preview.len().div_ceil(2) as f32 * 44.).max(218.);
let preview_ids: BTreeSet<_> =
node.preview.iter().map(|(id, _)| *id).collect();
node.preview_links = sequence_links(p, *source)
@@ -1071,11 +853,13 @@ impl Graph {
_ => {}
}
}
*slot += node.rect.height + 36.;
size.width = size.width.max(node.rect.x + node.rect.width + 28.);
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
nodes.push(node);
}
let mut edges = vec![];
let mut routed_pairs = BTreeSet::new();
for (i, r) in links.into_iter().enumerate() {
let a = nodes
.iter()
@@ -1089,11 +873,14 @@ impl Graph {
.rect;
let start = Point::new(a.x + a.width, a.center_y());
let end = Point::new(b.x, b.center_y());
let points = if end.x > start.x {
// Parallel records keep distinct visible paths and guard hit targets.
// Their semantic endpoints and IDs remain unchanged.
let forward = end.x > start.x && routed_pairs.insert((r.source(), r.target()));
let points = if forward {
let mid = (start.x + end.x) / 2.;
vec![start, Point::new(mid, start.y), Point::new(mid, end.y), end]
} else {
let below = size.height + 24. + i as f32 * 30.;
let below = size.height + 24. + i as f32 * 42.;
vec![
start,
Point::new(start.x + 18., start.y),
@@ -1107,7 +894,7 @@ impl Graph {
RelationBody::SequenceLink { guard, .. } => guard.clone(),
_ => String::new(),
};
let label_at = if end.x > start.x {
let label_at = if forward {
Point::new(end.x - 138., end.y - 43.)
} else {
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
@@ -1128,6 +915,9 @@ impl Graph {
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
size,
@@ -1204,47 +994,24 @@ fn draw_arrow(f: &mut Frame, points: &[Point], color: Color, width: f32) {
Stroke::default().with_color(color).with_width(width),
);
}
impl canvas::Program<Message> for Graph {
type State = ();
impl canvas::Program<Message> for Graph<'_> {
type State = Input;
fn update(
&self,
_: &mut (),
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
if matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && let Some(point) = cursor.position_in(bounds)
{
let point = Point::new(point.x / self.scale, point.y / self.scale);
if let Some(n) = self.nodes.iter().rev().find(|n| n.rect.contains(point)) {
let id = if point.y > n.rect.y + 75. {
n.source.unwrap_or(n.id)
} else {
n.id
};
return Some(Action::publish(Message::Select(id)).and_capture());
}
if let Some(e) = self.edges.iter().find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points
.windows(2)
.any(|v| crate::diagram::distance_to_segment(point, v[0], v[1]) < 8.)
}) {
return Some(Action::publish(Message::Select(e.id)).and_capture());
}
}
None
self.input(state, event, bounds, cursor)
}
fn draw(
&self,
_: &(),
state: &Input,
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
_: mouse::Cursor,
cursor: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
@@ -1327,7 +1094,7 @@ impl canvas::Program<Message> for Graph {
if n.source.is_some() {
draw_text(
&mut f,
"Open source →".into(),
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
@@ -1400,11 +1167,12 @@ impl canvas::Program<Message> for Graph {
);
}
}
self.overlay(state, &mut f, cursor, bounds);
vec![f.into_geometry()]
}
fn mouse_interaction(
&self,
_: &(),
_: &Input,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
@@ -1478,6 +1246,9 @@ mod tests {
}
fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft {
Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool: Tool::Branch,
name: String::new(),
@@ -1666,7 +1437,7 @@ mod tests {
let command = branch(owner, a, b, before, after).command(&p).unwrap();
session.execute(command).unwrap();
let result = session.project();
assert!(!result.relations.contains_key(&old_link));
assert!(result.relations.contains_key(&old_link));
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
assert_eq!(sequence_links(result, owner).len(), 6);
assert_eq!(
@@ -1777,10 +1548,26 @@ mod tests {
draft: Some(branch(owner, a, b, before, after)),
..State::default()
};
state.update(&p, Event::Guard(String::new())).unwrap();
state
.update(
&p,
Event::Target(Choice {
id: Id::nil(),
label: "Missing".into(),
}),
)
.unwrap();
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
state.update(&p, Event::Guard("ready".into())).unwrap();
state
.update(
&p,
Event::Target(Choice {
id: a,
label: "Work".into(),
}),
)
.unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
@@ -1836,9 +1623,8 @@ mod tests {
.unwrap();
let mut graph = Graph::new(&p, owner, owner);
let mut state = State::default();
state
.update(&p, Event::Viewport(Size::new(486., 460.)))
.unwrap();
state.viewport(Size::new(486., 460.));
state.zoom(None);
graph.scale = state.scale(graph.size, 500.);
assert!(graph.size.width * graph.scale <= 462.1);
assert!(graph.size.height * graph.scale <= 450.1);
@@ -1849,7 +1635,7 @@ mod tests {
);
let result = canvas::Program::update(
&graph,
&mut (),
&mut Input::default(),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
mouse::Cursor::Available(click),
@@ -1858,12 +1644,10 @@ mod tests {
.into_inner()
.0;
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
state.update(&p, Event::Zoom(1.)).unwrap();
state
.update(&p, Event::Viewport(Size::new(800., 460.)))
.unwrap();
state.zoom(Some(1.));
state.viewport(Size::new(800., 460.));
assert_eq!(state.scale(graph.size, 500.), 1.);
state.update(&p, Event::Fit).unwrap();
state.zoom(None);
assert!(state.scale(graph.size, 500.) < 1.);
}
}
@@ -0,0 +1,747 @@
//! Local pointer state and ordinary command proposals for the derived process graph.
use super::*;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Context {
pub document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Insertion {
pub link: Option<Id>,
pub before: Option<Id>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CanvasTool {
#[default]
Select,
Add(Tool, ControlKind),
Connect,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum End {
Source,
Target,
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
Scrolled(Context, scenario::ScrollArea, iced::Vector),
Choose(Context, CanvasTool),
Select(Context, Id),
AppendExchange(Context),
Begin {
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
},
Connect {
context: Context,
source: Id,
target: Id,
},
Reconnect {
context: Context,
link: Id,
end: End,
target: Id,
},
Reorder {
context: Context,
occurrence: Id,
before: Id,
},
Expand(Context, Id),
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
Viewport(Context, Size),
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(c, ..)
| Self::Choose(c, ..)
| Self::Select(c, ..)
| Self::Expand(c, ..)
| Self::Open(c, ..)
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
| Self::Connect { context, .. }
| Self::Reconnect { context, .. }
| Self::Reorder { context, .. } => *context,
}
}
}
impl State {
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.identity != Some((document, owner)) {
self.identity = Some((document, owner));
self.tool = CanvasTool::Select;
self.zoom = Some(1.);
self.fit = false;
self.viewport = None;
self.expanded.clear();
self.scroll_offsets = [iced::Vector::ZERO; 2];
}
}
pub(crate) fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
}
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
scenario::ScrollArea::Canvas => 0,
scenario::ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
pub(crate) fn choose(&mut self, tool: CanvasTool) {
self.tool = tool;
if tool != CanvasTool::Select && self.fit {
self.fit = false;
self.zoom = Some(1.);
}
}
pub(crate) fn zoom(&mut self, value: Option<f32>) {
self.fit = value.is_none();
self.zoom = value.map(|v| v.clamp(0.5, 2.));
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
let changed = self.viewport != Some(size);
self.viewport = Some(size);
changed
}
pub(crate) fn expand(&mut self, p: &Project, owner: Id, id: Id) -> bool {
if !is_contribution(p, owner, id)
|| !matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
)
{
return false;
}
if !self.expanded.remove(&id) {
self.expanded.insert(id);
}
true
}
pub(crate) fn remember(&mut self, owner: Id, occurrence: Id) {
self.return_to.push((owner, occurrence));
}
pub(crate) fn back(&mut self, p: &Project) -> Option<(Id, Id)> {
while let Some((owner, occurrence)) = self.return_to.pop() {
if is_contribution(p, owner, occurrence) {
return Some((owner, occurrence));
}
}
None
}
pub(crate) fn created_selection(&self, command: &Command) -> Option<Id> {
let owner = self.pending_owner()?;
let Command::Batch(commands) = command else {
return None;
};
commands.iter().find_map(|c| match c {
Command::CreateElement(e) if e.parent == Some(owner) => Some(e.id),
_ => None,
})
}
pub(crate) fn begin(
&mut self,
p: &Project,
context: Context,
tool: Tool,
control: ControlKind,
insertion: Insertion,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !matches!(
tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Control
) {
return Err("Choose a contribution tool.".into());
}
if !legacy_order(p, context.owner).is_empty() {
return Err("Convert the ordered path to explicit links before drawing.".into());
}
let link = insertion
.link
.map(|id| {
p.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, context.owner, r.source())
&& is_contribution(p, context.owner, r.target())
})
.ok_or("Choose a sequence link in this process.")
})
.transpose()?;
if insertion
.before
.is_some_and(|id| !is_contribution(p, context.owner, id))
{
return Err("Choose a presentation slot in this process.".into());
}
self.update(p, Event::Start(context.owner, tool, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.insertion = insertion;
d.control = control;
if let Some(r) = link {
d.before = Some(r.source());
d.after = Some(r.target());
}
Ok(())
}
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
let last = legacy_order(p, owner)
.last()
.copied()
.ok_or("Choose an ordered exchange path to append to.")?;
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
self.draft.as_mut().expect("started form").fixed = true;
Ok(())
}
pub(crate) fn connect(
&mut self,
p: &Project,
owner: Id,
source: Id,
target: Id,
) -> Result<(), String> {
if self.draft.is_some() {
return Err(PENDING_DRAFT.into());
}
if !is_contribution(p, owner, source)
|| !is_contribution(p, owner, target)
|| !validation::can_connect(p, RelationKind::SequenceLink, source, target)
{
return Err("Choose compatible contributions directly owned by this process.".into());
}
self.update(p, Event::Start(owner, Tool::Connect, None))?;
let d = self.draft.as_mut().expect("started form");
d.fixed = true;
d.before = Some(source);
d.after = Some(target);
Ok(())
}
}
pub(crate) fn referenced(p: &Project, owner: Id, occurrence: Id) -> Option<Id> {
if !is_contribution(p, owner, occurrence) {
return None;
}
match p.elements[&occurrence].shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action { action, .. },
..
} => *action,
SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
} => *exchange,
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence },
..
} => *sequence,
_ => None,
}
}
pub(crate) fn reconnect(
p: &Project,
owner: Id,
id: Id,
end: End,
endpoint: Id,
) -> Result<Command, String> {
let mut r = p
.relations
.get(&id)
.filter(|r| {
r.kind() == RelationKind::SequenceLink
&& is_contribution(p, owner, r.source())
&& is_contribution(p, owner, r.target())
})
.ok_or("Choose a sequence link in this process.")?
.clone();
if !is_contribution(p, owner, endpoint) {
return Err("Choose a local contribution endpoint.".into());
}
if let RelationBody::SequenceLink { source, target, .. } = &mut r.body {
match end {
End::Source => *source = endpoint,
End::Target => *target = endpoint,
}
}
let command = Command::EditRelation(id, r);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
pub(crate) fn reorder(p: &Project, owner: Id, id: Id, before: Id) -> Result<Command, String> {
if !is_contribution(p, owner, id) || !is_contribution(p, owner, before) {
return Err("Reorder contributions in this process only.".into());
}
let graph = Graph::new(p, owner, id);
let position = |id| graph.nodes.iter().find(|n| n.id == id).map(|n| n.rect.x);
if position(id) != position(before) {
return Err("Reorder within this column; links determine the path columns.".into());
}
let mut steps = p.elements[&owner].steps().unwrap().clone();
if id != before {
steps.retain(|v| *v != id);
let index = steps
.iter()
.position(|v| *v == before)
.ok_or("This slot no longer exists.")?;
steps.insert(index, id);
}
let command = Command::SetSteps(owner, steps);
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
Ok(command)
}
#[derive(Default)]
pub(super) struct Input {
context: Option<Context>,
tool: CanvasTool,
pointer: Option<Point>,
bounds: Option<Rectangle>,
source: Option<Id>,
press: Option<Press>,
}
#[derive(Clone, Copy)]
enum Press {
Connect {
source: Id,
first: bool,
start: Point,
moved: bool,
},
Reconnect {
link: Id,
end: End,
start: Point,
moved: bool,
},
Reorder {
occurrence: Id,
start: Point,
moved: bool,
},
Add {
insertion: Insertion,
},
}
impl Input {
fn cancel(&mut self) {
self.source = None;
self.press = None;
}
}
impl Graph<'_> {
fn node_at(&self, point: Point) -> Option<Id> {
self.nodes
.iter()
.rev()
.find(|n| n.rect.contains(point))
.map(|n| n.id)
}
fn edge_at(&self, point: Point) -> Option<Id> {
self.edges
.iter()
.find(|e| {
(!e.guard.is_empty() && e.label.contains(point))
|| e.points.windows(2).any(|v| {
crate::diagram::distance_to_segment(point, v[0], v[1]) < 8. / self.scale
})
})
.map(|e| e.id)
}
fn handle(&self, id: Id) -> Option<Rectangle> {
let n = self.nodes.iter().find(|n| n.id == id)?;
Some(Rectangle::new(
Point::new(n.rect.x + n.rect.width - 30., n.rect.y + 7.),
Size::new(23., 24.),
))
}
fn endpoint_at(&self, point: Point) -> Option<(Id, End)> {
let e = self.edges.iter().find(|e| e.id == self.selected)?;
[(e.points[0], End::Source), (*e.points.last()?, End::Target)]
.into_iter()
.find(|(p, _)| p.distance(point) < 10. / self.scale)
.map(|(_, end)| (e.id, end))
}
fn connectable(&self, source: Id, target: Id) -> bool {
validation::can_connect(self.project, RelationKind::SequenceLink, source, target)
}
pub(super) fn input(
&self,
state: &mut Input,
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
let (context, tool, editable) = self
.interaction
.map_or((None, CanvasTool::Select, false), |(c, t, e)| {
(Some(c), t, e)
});
if state.context != context
|| state.tool != tool
|| state.bounds.is_some_and(|b| b != bounds)
{
state.cancel();
state.pointer = None;
state.context = context;
state.tool = tool;
}
state.bounds = Some(bounds);
if !editable {
state.cancel();
}
let visible = cursor.position().is_some();
if !visible
&& matches!(
event,
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
)
{
state.cancel();
return Some(Action::request_redraw());
}
let cursor = match event {
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
state.pointer = Some(*position);
if let Some(
Press::Connect { start, moved, .. }
| Press::Reconnect { start, moved, .. }
| Press::Reorder { start, moved, .. },
) = &mut state.press
{
*moved |= position.distance(*start) >= 3.;
}
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
| canvas::Event::Window(
iced::window::Event::Resized(_) | iced::window::Event::Unfocused,
) => {
state.cancel();
state.pointer = None;
return Some(Action::request_redraw());
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
}) => {
state.cancel();
return Some(Action::request_redraw().and_capture());
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
_ => cursor,
};
let world = cursor
.position_in(bounds)
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture());
match event {
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => {
let point = world?;
let Some(context) = context else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.map(|id| Action::publish(Message::Select(id)).and_capture());
};
if !editable {
return None;
}
let start = cursor.position()? - self.scroll_offset;
match tool {
CanvasTool::Add(..) => {
state.press = Some(Press::Add {
insertion: Insertion {
link: self
.edge_at(point)
.filter(|_| self.node_at(point).is_none()),
before: self.node_at(point),
},
});
}
CanvasTool::Connect => {
let first = state.source.is_none();
let source = state.source.or_else(|| self.node_at(point))?;
state.source = Some(source);
state.press = Some(Press::Connect {
source,
first,
start,
moved: false,
});
}
CanvasTool::Select => {
if let Some((link, end)) = self.endpoint_at(point) {
state.press = Some(Press::Reconnect {
link,
end,
start,
moved: false,
});
} else if self
.handle(self.selected)
.is_some_and(|r| r.contains(point))
{
state.press = Some(Press::Reorder {
occurrence: self.selected,
start,
moved: false,
});
} else {
return self
.node_at(point)
.or_else(|| self.edge_at(point))
.and_then(|id| publish(CanvasEvent::Select(context, id)));
}
}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
let press = state.press.take()?;
let context = context?;
match press {
Press::Add { insertion } if world.is_some() => {
if let CanvasTool::Add(tool, control) = tool {
state.cancel();
return publish(CanvasEvent::Begin {
context,
tool,
control,
insertion,
});
}
}
Press::Connect {
source,
first,
moved,
..
} if moved || !first => {
if let Some(target) = world
.and_then(|p| self.node_at(p))
.filter(|target| self.connectable(source, *target))
{
state.cancel();
return publish(CanvasEvent::Connect {
context,
source,
target,
});
}
}
Press::Reconnect {
link,
end,
moved: true,
..
} => {
if let Some(target) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reconnect {
context,
link,
end,
target,
});
}
}
Press::Reorder {
occurrence,
moved: true,
..
} => {
if let Some(before) = world.and_then(|p| self.node_at(p)) {
state.cancel();
return publish(CanvasEvent::Reorder {
context,
occurrence,
before,
});
}
}
_ => {}
}
Some(Action::request_redraw().and_capture())
}
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) => {
Some(Action::request_redraw())
}
_ => None,
}
}
pub(super) fn overlay(
&self,
state: &Input,
f: &mut Frame,
cursor: mouse::Cursor,
bounds: Rectangle,
) {
let Some((context, tool, editable)) = self.interaction else {
return;
};
if !editable {
return;
}
let point = state
.pointer
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale));
let live =
state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds);
if live && let Some(source) = state.source {
for n in &self.nodes {
if n.id == source || self.connectable(source, n.id) {
f.stroke(
&Path::rounded_rectangle(n.rect.position(), n.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let (Some(n), Some(to)) = (self.nodes.iter().find(|n| n.id == source), point) {
draw_arrow(
f,
&[Point::new(n.rect.x + n.rect.width, n.rect.center_y()), to],
style::TEAL,
2.,
);
}
}
if live
&& let Some(Press::Reconnect { link, end, .. }) = state.press
&& let Some(edge) = self.edges.iter().find(|e| e.id == link)
{
for node in &self.nodes {
let (source, target) = match end {
End::Source => (node.id, edge.target),
End::Target => (edge.source, node.id),
};
if self.connectable(source, target) {
f.stroke(
&Path::rounded_rectangle(node.rect.position(), node.rect.size(), 10.into()),
Stroke::default().with_color(style::TEAL).with_width(2.5),
);
}
}
if let Some(point) = point {
let (from, to) = match end {
End::Source => (point, *edge.points.last().unwrap()),
End::Target => (edge.points[0], point),
};
draw_arrow(f, &[from, to], style::TEAL, 2.5);
}
}
if live
&& let Some(Press::Reorder { occurrence, .. }) = state.press
&& let Some(point) = point
&& let Some(target) = self.node_at(point)
{
let source = self.nodes.iter().find(|n| n.id == occurrence).unwrap();
let target = self.nodes.iter().find(|n| n.id == target).unwrap();
if source.rect.x == target.rect.x {
f.stroke(
&Path::line(
Point::new(target.rect.x, target.rect.y - 7.),
Point::new(target.rect.x + target.rect.width, target.rect.y - 7.),
),
Stroke::default().with_color(style::TEAL).with_width(3.),
);
draw_text(
f,
"Order before · paths unchanged".into(),
Point::new(target.rect.x, target.rect.y - 24.),
12.,
style::TEAL,
);
}
}
if tool == CanvasTool::Select {
if let Some(r) = self.handle(self.selected) {
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
draw_text(f, "↕".into(), r.position(), 17., style::TEAL);
}
if let Some(e) = self.edges.iter().find(|e| e.id == self.selected) {
for p in [e.points[0], *e.points.last().unwrap()] {
f.fill(&Path::circle(p, 6. / self.scale), style::TEAL);
}
}
}
if let CanvasTool::Add(..) = tool
&& let Some(point) = point
{
let at = self
.edge_at(point)
.filter(|_| self.node_at(point).is_none());
let caption = if let Some(id) = at {
if let Some(e) = self.edges.iter().find(|e| e.id == id) {
draw_arrow(f, &e.points, style::TEAL, 3.);
}
"Insert on this path · preserve its guard"
} else if self.node_at(point).is_some() {
"Add before this contribution · unconnected"
} else {
"Append contribution · unconnected"
};
draw_text(
f,
caption.into(),
Point::new(point.x + 12., point.y + 16.),
12. / self.scale,
style::TEAL,
);
}
}
}
impl State {
pub(crate) fn selection_offset(
&self,
p: &Project,
owner: Id,
id: Id,
) -> Option<scrollable::AbsoluteOffset> {
let graph = Graph::expanded(p, owner, id, &self.expanded);
let scale = self.scale(graph.size, 500.);
let rect = graph
.nodes
.iter()
.find(|n| n.id == id)
.map(|n| n.rect)
.or_else(|| graph.edges.iter().find(|e| e.id == id).map(|e| e.label))?;
Some(scrollable::AbsoluteOffset {
x: (rect.x * scale - 12.).max(0.),
y: (rect.y * scale - 12.).max(0.),
})
}
}
@@ -0,0 +1,970 @@
use super::*;
use crate::document::Document;
use oa_core::source_project::{ProjectLimits, SourceBundle};
pub(crate) struct Fixture {
pub p: Project,
pub owner: Id,
pub work: [Id; 3],
pub nodes: [Id; 3],
pub exchange: Id,
pub link: Id,
}
pub(crate) fn fixture(layer: Layer) -> Fixture {
let mut p = Project::empty();
let owner = p
.make_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Respond safely",
None,
)
.unwrap();
let owner_id = owner.id;
p = execute_batch(&p, &[Command::CreateElement(owner)]).unwrap();
let mut work = Vec::new();
let mut nodes = Vec::new();
for name in ["Assess", "Respond", "Recover"] {
let e = p
.make_element(
if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
},
layer,
name,
None,
)
.unwrap();
let id = e.id;
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
work.push(id);
let e = occurrence(
&p,
owner_id,
name,
OccurrenceBody::Action {
action: Some(id),
lifeline: None,
},
);
let id = e.id;
p = execute_batch(&p, &[Command::CreateElement(e)]).unwrap();
nodes.push(id);
}
let kind = if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
};
p = execute_batch(
&p,
&[Command::Connect(
kind,
"Assessment".into(),
work[0],
work[1],
)],
)
.unwrap();
let exchange = p.relations.values().find(|r| r.kind() == kind).unwrap().id;
let Command::CreateRelation(mut r) = link(&p, nodes[0], nodes[1], "approved") else {
unreachable!()
};
r.alias = "assessed_path".into();
r.description = "Reviewed safety rationale".into();
let link_id = r.id;
p = execute_batch(&p, &[Command::CreateRelation(r)]).unwrap();
Fixture {
p,
owner: owner_id,
work: work.try_into().unwrap(),
nodes: nodes.try_into().unwrap(),
exchange,
link: link_id,
}
}
pub(crate) fn context(owner: Id) -> Context {
Context {
document: Id::new_v4(),
owner,
revision: 0,
epoch: 0,
}
}
fn command(f: &Fixture, tool: Tool, target: Option<Id>, insertion: Insertion) -> Command {
let mut state = State::default();
state
.begin(&f.p, context(f.owner), tool, ControlKind::Choice, insertion)
.unwrap();
if let Some(id) = target {
state
.update(
&f.p,
Event::Target(Choice {
id,
label: "Source".into(),
}),
)
.unwrap();
}
state.prepare_pending(&f.p).unwrap()
}
fn created(p: &Project, after: &Project, owner: Id) -> Id {
after
.children(owner)
.find(|e| !p.contains(e.id))
.unwrap()
.id
}
#[test]
fn direct_process_oa_sa_repeated_occurrences_share_sources_and_split_one_guarded_link() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer);
let original = f.p.clone();
let add = command(&f, Tool::Work, Some(f.work[0]), Insertion::default());
let result = execute_batch(&f.p, &[add]).unwrap();
let repeated = created(&f.p, &result, f.owner);
assert_ne!(repeated, f.nodes[0]);
assert_eq!(result.elements[&f.work[0]], f.p.elements[&f.work[0]]);
assert!(
matches!(result.elements[&repeated].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Action{action:Some(id),..},..})if *id==f.work[0])
);
f.p = result;
let plan = command(
&f,
Tool::Exchange,
Some(f.exchange),
Insertion {
link: Some(f.link),
before: None,
},
);
let serialized = serde_json::to_string(&plan).unwrap();
let api: Command = serde_json::from_str(&serialized).unwrap();
assert_eq!(api, plan);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let before = doc.bundle().clone();
doc.execute(plan).unwrap();
let inserted = created(&f.p, doc.project(), f.owner);
let r = &doc.project().relations[&f.link];
let expected = &f.p.relations[&f.link];
assert_eq!(r.id, expected.id);
assert_eq!(r.alias, expected.alias);
assert_eq!(r.description, expected.description);
assert_eq!(r.source(), expected.source());
assert_eq!(r.target(), inserted);
assert!(matches!(&r.body,RelationBody::SequenceLink{guard,..}if guard=="approved"));
assert!(
sequence_links(doc.project(), f.owner)
.iter()
.any(|r| r.source() == inserted && r.target() == f.nodes[1])
);
let steps = doc.project().elements[&f.owner].steps().unwrap();
assert_eq!(&steps[..3], &[f.nodes[0], inserted, f.nodes[1]]);
assert_eq!(
doc.project().relations[&f.exchange],
original.relations[&f.exchange]
);
assert_eq!(doc.project(), &execute_batch(&f.p, &[api]).unwrap());
let after = doc.bundle().clone();
assert!(doc.undo());
assert_eq!(doc.bundle(), &before);
assert!(doc.redo());
assert_eq!(doc.bundle(), &after);
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), doc.project());
}
}
#[test]
fn direct_process_specific_parallel_link_and_incompatible_adjacency() {
let mut f = fixture(Layer::Oa);
let Command::CreateRelation(mut second) = link(&f.p, f.nodes[0], f.nodes[1], "urgent") else {
unreachable!()
};
second.alias = "urgent_path".into();
let second_id = second.id;
f.p = execute_batch(&f.p, &[Command::CreateRelation(second)]).unwrap();
let graph = Graph::new(&f.p, f.owner, f.owner);
for edge in &graph.edges {
let clicked = send(
&graph,
&mut Input::default(),
mouse::Event::ButtonPressed(mouse::Button::Left),
edge.label.center(),
Rectangle::new(Point::ORIGIN, graph.size),
);
assert!(
matches!(clicked,Some(Message::Select(id))if id==edge.id),
"Each guarded parallel path must be individually selectable"
);
}
let plan = command(
&f,
Tool::Control,
None,
Insertion {
link: Some(f.link),
before: None,
},
);
let after = execute_batch(&f.p, &[plan]).unwrap();
assert_eq!(after.relations[&second_id], f.p.relations[&second_id]);
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Exchange,
ControlKind::Choice,
Insertion {
link: Some(f.link),
before: None,
},
)
.unwrap();
let wrong = execute_batch(
&f.p,
&[Command::Connect(
RelationKind::Interaction,
"Reverse".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let wrong_id = wrong
.relations
.values()
.find(|r| r.name == "Reverse")
.unwrap()
.id;
state
.update(
&wrong,
Event::Target(Choice {
id: wrong_id,
label: "Wrong".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&wrong).is_err());
assert!(state.draft.is_some());
assert!(direct::reconnect(&f.p, f.owner, f.link, direct::End::Target, f.nodes[0]).is_err());
}
#[test]
fn direct_process_controls_allow_incomplete_work_and_preserve_branch_semantics() {
let f = fixture(Layer::Oa);
for kind in [
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
] {
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Control,
kind,
Insertion::default(),
)
.unwrap();
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
let id = created(&f.p, &result, f.owner);
assert!(
matches!(result.elements[&id].shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Control{kind:k},..})if *k==kind)
);
assert!(
validation::validate(&result)
.iter()
.any(|d| d.element == Some(id)
&& d.code == "control_cardinality"
&& d.severity == validation::Severity::Warning)
);
}
for kind in [ControlKind::Choice, ControlKind::Fork] {
let mut state = State::default();
state
.update(&f.p, Event::Start(f.owner, Tool::Branch, Some(f.nodes[0])))
.unwrap();
for event in [
Event::Target(Choice {
id: f.work[0],
label: "A".into(),
}),
Event::Alternate(Choice {
id: f.work[2],
label: "B".into(),
}),
Event::Control(ControlChoice(kind)),
Event::Guard("ready".into()),
] {
state.update(&f.p, event).unwrap();
}
let result = execute_batch(&f.p, &[state.prepare_pending(&f.p).unwrap()]).unwrap();
assert!(
matches!(&result.relations[&f.link].body,RelationBody::SequenceLink{guard,..}if guard=="approved")
);
let branch_guards: Vec<_> = sequence_links(&result, f.owner)
.into_iter()
.filter(|r| !f.p.relations.contains_key(&r.id))
.filter_map(|r| {
if let RelationBody::SequenceLink { guard, .. } = &r.body {
Some(guard.as_str())
} else {
None
}
})
.collect();
assert_eq!(
branch_guards.contains(&"ready"),
kind == ControlKind::Choice
);
assert_eq!(branch_guards.contains(&"else"), kind == ControlKind::Choice);
}
}
#[test]
fn direct_process_reconnect_delete_and_reorder_preserve_other_paths_and_sources() {
let f = fixture(Layer::Oa);
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
doc.execute(
direct::reconnect(
doc.project(),
f.owner,
f.link,
direct::End::Target,
f.nodes[2],
)
.unwrap(),
)
.unwrap();
assert_eq!(doc.project().relations[&f.link].target(), f.nodes[2]);
assert_eq!(doc.project().relations[&f.link].alias, "assessed_path");
let reconnected = doc.bundle().clone();
doc.execute(Command::Delete(f.nodes[0])).unwrap();
assert!(doc.project().contains(f.work[0]));
assert!(doc.project().contains(f.exchange));
assert!(doc.project().contains(f.nodes[1]));
assert!(!doc.project().contains(f.link));
doc.undo();
assert_eq!(doc.bundle(), &reconnected);
doc.undo();
assert_eq!(doc.bundle(), &original);
let reordered = execute_batch(
&f.p,
&[direct::reorder(&f.p, f.owner, f.nodes[2], f.nodes[0]).unwrap()],
)
.unwrap();
assert_eq!(reordered.elements[&f.owner].steps().unwrap()[0], f.nodes[2]);
assert_eq!(reordered.relations, f.p.relations);
assert!(direct::reorder(&f.p, f.owner, f.nodes[0], f.nodes[1]).is_err());
}
#[test]
fn direct_process_reuse_preview_is_local_expandable_and_recursive_sources_are_rejected() {
let mut f = fixture(Layer::Oa);
let source =
f.p.make_element(Kind::Process, Layer::Oa, "Reusable", None)
.unwrap();
let source_id = source.id;
f.p = execute_batch(&f.p, &[Command::CreateElement(source)]).unwrap();
for i in 0..8 {
let e = occurrence(
&f.p,
source_id,
&format!("Shared step {i}"),
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let shared = f.p.clone();
let mut references = Vec::new();
for _ in 0..2 {
let plan = command(&f, Tool::Reuse, Some(source_id), Insertion::default());
let result = execute_batch(&f.p, &[plan]).unwrap();
references.push(created(&f.p, &result, f.owner));
f.p = result;
}
let graph = Graph::new(&f.p, f.owner, references[0]);
let node = graph.nodes.iter().find(|n| n.id == references[0]).unwrap();
assert_eq!(node.preview.len(), 4);
assert_eq!(node.preview_total, 8);
let mut input = Input::default();
let point = Point::new(node.rect.x + 20., node.rect.y + 140.);
let clicked = send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
Rectangle::new(Point::ORIGIN, graph.size),
);
assert!(
matches!(clicked,Some(Message::Select(id))if id==references[0]),
"preview click must select the local reference"
);
let expanded = Graph::expanded(
&f.p,
f.owner,
references[0],
&BTreeSet::from([references[0]]),
);
assert_eq!(
expanded
.nodes
.iter()
.find(|n| n.id == references[0])
.unwrap()
.preview
.len(),
8
);
assert_eq!(
expanded
.nodes
.iter()
.find(|n| n.id == references[1])
.unwrap()
.preview
.len(),
4
);
for e in shared.children(source_id) {
assert_eq!(f.p.elements[&e.id], *e);
}
assert!(
!targets(&f.p, source_id, Tool::Reuse)
.iter()
.any(|c| c.id == f.owner)
);
let inside = f.p.elements[&source_id].steps().unwrap()[0];
assert!(!validation::can_connect(
&f.p,
RelationKind::SequenceLink,
references[0],
inside
));
}
#[test]
fn direct_process_nested_source_reopen_and_api_command_use_one_transaction() {
let bundle=SourceBundle{entry:"project.arcadia".into(),files:[
("project.arcadia".into(),"arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; include \"oa/process.arcadia\"; }\n".into()),
("oa/work.arcadia".into(),"// Shared work\noa_activity assess \"Assess\" {}\n".into()),
("oa/process.arcadia".into(),"// Contextual process\noa_process respond \"Respond\" {}\n".into()),
].into()};
let mut doc = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
let original = doc.bundle().clone();
let owner = doc
.project()
.elements
.values()
.find(|e| e.alias == "respond")
.unwrap()
.id;
let work = doc
.project()
.elements
.values()
.find(|e| e.alias == "assess")
.unwrap()
.id;
let mut state = State::default();
state
.begin(
doc.project(),
context(owner),
Tool::Work,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
doc.project(),
Event::Target(Choice {
id: work,
label: "Assess".into(),
}),
)
.unwrap();
let plan = state.prepare_pending(doc.project()).unwrap();
let expected = execute_batch(
doc.project(),
&[serde_json::from_value(serde_json::to_value(&plan).unwrap()).unwrap()],
)
.unwrap();
doc.execute(plan).unwrap();
let id = doc.project().elements[&owner].steps().unwrap()[0];
assert_eq!(
doc.compiled().index.declarations[&id].path,
"oa/process.arcadia"
);
assert_eq!(
doc.bundle().files["oa/work.arcadia"],
original.files["oa/work.arcadia"]
);
assert_eq!(doc.project(), &expected);
let after = doc.bundle().clone();
doc.undo();
assert_eq!(doc.bundle(), &original);
doc.redo();
assert_eq!(doc.bundle(), &after);
let reopened = Document::from_bundle(after, ProjectLimits::default()).unwrap();
assert_eq!(reopened.project(), &expected);
}
fn graph(f: &Fixture, tool: CanvasTool) -> Graph<'_> {
let mut graph = Graph::new(&f.p, f.owner, f.owner);
graph.interaction = Some((context(f.owner), tool, true));
graph
}
fn send(
graph: &Graph<'_>,
input: &mut Input,
event: mouse::Event,
point: Point,
bounds: Rectangle,
) -> Option<Message> {
canvas::Program::update(
graph,
input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point + graph.scroll_offset),
)
.and_then(|a| a.into_inner().0)
}
fn position(graph: &Graph<'_>, id: Id) -> Point {
graph
.nodes
.iter()
.find(|n| n.id == id)
.unwrap()
.rect
.center()
}
#[test]
fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() {
let f = fixture(Layer::Oa);
for drag in [false, true] {
let mut graph = graph(&f, CanvasTool::Connect);
graph.scroll_offset = iced::Vector::new(35., 90.);
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
let screen =
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
let a = screen(f.nodes[0]);
let b = screen(f.nodes[2]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: a },
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
if !drag {
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
a,
bounds
)
.is_none()
);
}
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
if !drag {
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
b,
bounds,
);
}
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Connect{source,target,..}))if source==f.nodes[0]&&target==f.nodes[2])
);
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
bounds
)
.is_none()
);
}
let graph = graph(&f, CanvasTool::Add(Tool::Exchange, ControlKind::Choice));
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
let e = &graph.edges[0];
let point = Point::new((e.points[0].x + e.points[1].x) / 2., e.points[0].y);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
point,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),point,bounds),Some(Message::TopologyCanvas(CanvasEvent::Begin{insertion:Insertion{link:Some(id),..},..}))if id==f.link)
);
}
#[test]
fn direct_process_production_stale_focus_scroll_bounds_and_pending_cancel() {
let f = fixture(Layer::Oa);
for change in 0..8 {
let mut graph = graph(&f, CanvasTool::Connect);
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
let a = position(&graph, f.nodes[0]);
let b = position(&graph, f.nodes[2]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
let mut end_bounds = bounds;
match change {
0 => graph.interaction.as_mut().unwrap().0.epoch += 1,
1 => graph.interaction.as_mut().unwrap().2 = false,
2 => end_bounds.y -= 40.,
3 => {
canvas::Program::update(
&graph,
&mut input,
&canvas::Event::Window(iced::window::Event::Unfocused),
bounds,
mouse::Cursor::Available(b),
);
}
4 => {
send(
&graph,
&mut input,
mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
},
b,
bounds,
);
}
5 => graph.interaction.as_mut().unwrap().0.document = Id::new_v4(),
6 => graph.interaction.as_mut().unwrap().1 = CanvasTool::Select,
_ => {
canvas::Program::update(
&graph,
&mut input,
&canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
modified_key: iced::keyboard::Key::Named(
iced::keyboard::key::Named::Escape,
),
physical_key: iced::keyboard::key::Physical::Code(
iced::keyboard::key::Code::Escape,
),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
bounds,
mouse::Cursor::Available(b),
);
}
}
assert!(
send(
&graph,
&mut input,
mouse::Event::ButtonReleased(mouse::Button::Left),
b,
end_bounds
)
.is_none(),
"change {change}"
);
}
}
#[test]
fn direct_process_readable_default_fit_expansion_and_bounded_choices() {
let f = fixture(Layer::Oa);
let mut state = State::default();
state.enter(Id::new_v4(), f.owner);
state.viewport(Size::new(500., 400.));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
state.zoom(None);
assert!(state.scale(Size::new(3262., 220.), 500.) < 0.2);
state.zoom(Some(0.1));
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 0.5);
state.enter(Id::new_v4(), f.owner);
assert_eq!(state.scale(Size::new(3262., 220.), 500.), 1.);
let mut all = Vec::new();
for page in 0..500usize.div_ceil(24) {
let range = view::choice_page(500, page);
assert!(range.len() <= 24);
all.extend(range);
}
assert_eq!(all, (0..500).collect::<Vec<_>>());
assert_eq!(view::choice_page(0, 90), 0..0);
}
#[test]
fn direct_process_production_endpoint_and_peer_reorder_handles_publish_exact_identity() {
let f = fixture(Layer::Oa);
let mut graph = graph(&f, CanvasTool::Select);
let bounds = Rectangle::new(Point::ORIGIN, graph.size);
graph.selected = f.link;
let mut input = Input::default();
let a = *graph
.edges
.iter()
.find(|e| e.id == f.link)
.unwrap()
.points
.last()
.unwrap();
let b = position(&graph, f.nodes[2]);
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reconnect{link,end:direct::End::Target,target,..}))if link==f.link&&target==f.nodes[2])
);
graph.selected = f.nodes[2];
let n = graph.nodes.iter().find(|n| n.id == f.nodes[2]).unwrap();
let a = Point::new(n.rect.x + n.rect.width - 20., n.rect.y + 18.);
let b = position(&graph, f.nodes[0]);
let mut input = Input::default();
send(
&graph,
&mut input,
mouse::Event::ButtonPressed(mouse::Button::Left),
a,
bounds,
);
send(
&graph,
&mut input,
mouse::Event::CursorMoved { position: b },
b,
bounds,
);
assert!(
matches!(send(&graph,&mut input,mouse::Event::ButtonReleased(mouse::Button::Left),b,bounds),Some(Message::TopologyCanvas(CanvasEvent::Reorder{occurrence,before,..}))if occurrence==f.nodes[2]&&before==f.nodes[0])
);
}
#[test]
fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() {
let f = fixture(Layer::Oa);
let mut state = State::default();
state
.begin(
&f.p,
context(f.owner),
Tool::Work,
ControlKind::Choice,
Insertion::default(),
)
.unwrap();
state
.update(
&f.p,
Event::Target(Choice {
id: f.work[0],
label: "Keep".into(),
}),
)
.unwrap();
let mut choices: Vec<_> = (0..500)
.map(|i| Choice {
id: Id::new_v4(),
label: format!("Activity {i}"),
})
.collect();
choices.push(Choice {
id: f.work[0],
label: "Selected source".into(),
});
fn nodes(tree: &iced::advanced::widget::Tree) -> usize {
1 + tree.children.iter().map(nodes).sum::<usize>()
}
for page in 0..21 {
state.update(&f.p, Event::Page(0, page)).unwrap();
let d = state.draft.as_ref().unwrap();
let view = view::picker(choices.clone(), d, 0, d.target, "Choose", Event::Target);
assert!(nodes(&iced::advanced::widget::Tree::new(view.as_widget())) < 40);
}
state
.update(&f.p, Event::Search(0, "Activity 499".into()))
.unwrap();
assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0]));
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
}
pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) {
let mut f = fixture(layer);
let mut commands = f.nodes.into_iter().map(Command::Delete).collect::<Vec<_>>();
commands.push(Command::Connect(
f.p.relations[&f.exchange].kind(),
"Response".into(),
f.work[1],
f.work[2],
));
f.p = execute_batch(&f.p, &commands).unwrap();
let next =
f.p.relations
.values()
.find(|r| r.name == "Response")
.unwrap()
.id;
assert!(f.p.elements[&f.owner].steps().unwrap().is_empty());
(f, next)
}
#[test]
fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
let kind = f.p.relations[&f.exchange].kind();
f.p = execute_batch(
&f.p,
&[Command::Connect(
kind,
"Recovery".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let final_exchange =
f.p.relations
.values()
.find(|r| r.name == "Recovery")
.unwrap()
.id;
for exchange in [f.exchange, next] {
let e = occurrence(
&f.p,
f.owner,
"Ordered contribution",
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let before = f.p.elements[&f.owner].steps().unwrap().clone();
assert_eq!(legacy_order(&f.p, f.owner), before);
assert!(sequence_links(&f.p, f.owner).is_empty());
let mut state = State::default();
state.append_exchange(&f.p, f.owner).unwrap();
let draft = state.draft.as_ref().unwrap();
assert!(draft.fixed);
assert_eq!(draft.before, before.last().copied());
assert_eq!(draft.after, None);
assert!(!compatible_source(&f.p, draft, f.exchange));
assert!(compatible_source(&f.p, draft, final_exchange));
state
.update(
&f.p,
Event::Target(Choice {
id: f.exchange,
label: "Incompatible".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&f.p).is_err());
assert!(state.draft.is_some());
state
.update(
&f.p,
Event::Target(Choice {
id: final_exchange,
label: "Recovery".into(),
}),
)
.unwrap();
let command = state.prepare_pending(&f.p).unwrap();
let Command::Batch(commands) = &command else {
panic!("one ordinary batch")
};
assert_eq!(
commands.len(),
4,
"materialize old path + occurrence + append link + order"
);
let serialized = serde_json::to_value(command).unwrap();
let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap();
let after = result.elements[&f.owner].steps().unwrap();
assert_eq!(&after[..2], &before);
assert_eq!(after.len(), 3);
let links = sequence_links(&result, f.owner);
assert_eq!(links.len(), 2);
for pair in after.windows(2) {
assert_eq!(
links
.iter()
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
.count(),
1
);
}
assert!(uses_explicit_topology(&result, f.owner));
for (id, relation) in &f.p.relations {
assert_eq!(result.relations[id], *relation);
}
}
}
@@ -0,0 +1,478 @@
use super::*;
use iced::widget::column;
pub(super) fn picker<'a>(
mut choices: Vec<Choice>,
d: &'a Draft,
index: usize,
value: Option<Id>,
placeholder: &'a str,
change: fn(Choice) -> Event,
) -> Element<'a, Message> {
let selected = value
.and_then(|id| choices.iter().find(|c| c.id == id))
.cloned();
let browser = &d.browsers[index];
let query = browser.search.trim().to_lowercase();
choices.retain(|c| c.label.to_lowercase().contains(&query));
let range = choice_page(choices.len(), browser.page);
let page = range.start / 24;
let mut out = column![
text_input("Search compatible choices…", &browser.search)
.on_input(move |v| event(Event::Search(index, v)))
.padding(7)
.size(12)
]
.spacing(5);
if let Some(selected) = selected
.as_ref()
.filter(|c| !choices[range.clone()].iter().any(|v| v.id == c.id))
{
out = out.push(label(format!("Selected: {}", selected.label)));
}
out = out.push(
row![
button("Previous")
.on_press_maybe((page > 0).then(|| event(Event::Page(index, page - 1)))),
text(format!(
"{} choices · {}/{}",
choices.len(),
page + 1,
choices.len().div_ceil(24).max(1)
))
.size(11),
button("Next").on_press_maybe(
(range.end < choices.len()).then(|| event(Event::Page(index, page + 1)))
)
]
.spacing(6),
);
out.push(
pick_list(choices[range].to_vec(), selected, move |v| event(change(v)))
.placeholder(placeholder)
.padding(8)
.text_size(12)
.width(Fill),
)
.into()
}
pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize> {
let start = requested.min(len.saturating_sub(1) / 24) * 24;
start..(start + 24).min(len)
}
pub(super) fn view<'a>(
app: &'a App,
owner: Id,
mut out: Column<'a, Message>,
) -> Element<'a, Message> {
let p = app.project();
let context = app.topology_context().expect("visible process context");
let state = &app.behavior.topology;
let pending = state.draft.is_some();
let explicit =
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
if !pending && explicit {
out = out
.push(
row![
button("Select / reorder")
.style(if state.tool == CanvasTool::Select {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Select)),
button("Work")
.style(
if state.tool == CanvasTool::Add(Tool::Work, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Work, ControlKind::Choice))),
button("Exchange")
.style(
if state.tool == CanvasTool::Add(Tool::Exchange, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Exchange, ControlKind::Choice))),
button("Reuse")
.style(
if state.tool == CanvasTool::Add(Tool::Reuse, ControlKind::Choice) {
button::primary
} else {
style::nav
}
)
.on_press(choose(CanvasTool::Add(Tool::Reuse, ControlKind::Choice)))
]
.spacing(6),
)
.push(
row![
pick_list(
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop
]
.into_iter()
.map(ControlChoice)
.collect::<Vec<_>>(),
if let CanvasTool::Add(Tool::Control, kind) = state.tool {
Some(ControlChoice(kind))
} else {
None
},
move |c| Message::TopologyCanvas(CanvasEvent::Choose(
context,
CanvasTool::Add(Tool::Control, c.0)
))
)
.placeholder("Draw control…")
.text_size(12)
.padding(8),
button("Sequence link")
.style(if state.tool == CanvasTool::Connect {
button::primary
} else {
style::nav
})
.on_press(choose(CanvasTool::Connect)),
button("Two paths…").on_press(event(Event::Start(
owner,
Tool::Branch,
selected
)))
]
.spacing(6),
);
}
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
if explicit {
let mut graph = Graph::expanded(p, owner, app.selected, &state.expanded);
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.interaction = Some((
context,
state.tool,
!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty(),
));
graph.scroll_offset = state.scroll_offset();
let scale = graph.scale;
out = out.push(
row![
button("Fit overview")
.on_press(Message::TopologyCanvas(CanvasEvent::Zoom(context, None))),
button("−").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
label(format!("{:.0}%", scale * 100.)),
button("+").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button("100%").on_press(Message::TopologyCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6),
);
if graph.nodes.is_empty() {
out = out.push(label(
"Choose Work, Exchange, Reuse or a control, then click the canvas to start.",
));
}
let size = Size::new(graph.size.width * scale, graph.size.height * scale);
let canvas = scrollable(
canvas::Canvas::new(graph)
.width(size.width)
.height(size.height),
)
.id("process-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(if pending {
(app.window_size.height - 510.).clamp(160., 240.)
} else {
(app.window_size.height - 420.).clamp(190., 420.)
});
out = out.push(
iced::widget::sensor(
container(super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Canvas,
canvas,
))
.padding(8)
.width(Fill)
.style(style::card),
)
.on_show(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::TopologyCanvas(CanvasEvent::Viewport(context, size))),
);
} else {
out = out.push(label(
"Ordered exchange path · append the next exchange or convert to explicit links.",
));
if !pending {
out = out.push(
button("Append exchange…")
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
}
let commands = preserved_order(p, owner, None);
if !commands.is_empty() {
out = out.push(
button("Convert ordered path to explicit links")
.on_press(Message::Command(unique_batch(p, commands))),
);
} else {
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
}
let scene = Scene::sequence(p, owner, app.selected);
let size = scene.size;
out = out.push(
scrollable(
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height),
)
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.height(240.),
);
}
if let Some(id) = selected {
let mut actions =
row![button("Contribution properties").on_press(Message::EditProperties(id))]
.spacing(6);
if let Some(target) = direct::referenced(p, owner, id) {
actions = actions.push(
button(text(format!("Open source: {}", short(p.named(target), 28))).size(12))
.on_press(Message::TopologyCanvas(CanvasEvent::Open(context, id))),
);
if matches!(
p.elements[&id].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { .. },
..
})
) {
actions = actions.push(
button(if state.expanded.contains(&id) {
"Collapse"
} else {
"Expand"
})
.on_press(Message::TopologyCanvas(CanvasEvent::Expand(context, id))),
);
}
}
out = out.push(actions);
} else if sequence_links(p, owner)
.iter()
.any(|r| r.id == app.selected)
{
out = out.push(
button("Path properties / guard").on_press(Message::EditProperties(app.selected)),
);
}
if !state.return_to.is_empty() {
out = out.push(
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
);
}
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
let mut form = column![text(d.tool.title()).size(16)].spacing(8);
if let Some(link) = d.insertion.link.and_then(|id| p.relations.get(&id)) {
let guard = if let RelationBody::SequenceLink { guard, .. } = &link.body {
guard.as_str()
} else {
""
};
form = form.push(label(format!(
"Insert on {} → {} · preserve path identity and condition [{}]",
p.named(link.source()),
p.named(link.target()),
guard
)));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion
.before
.map_or("Append an unconnected contribution".into(), |id| {
format!("Unconnected contribution before {}", p.named(id))
}),
));
}
if !legacy_order(p, owner).is_empty() {
form = form.push(label(
"This edit preserves the ordered path as explicit links in the same undo step.",
));
}
if matches!(
d.tool,
Tool::Work | Tool::Exchange | Tool::Reuse | Tool::Branch
) {
let choices = targets(p, owner, d.tool)
.into_iter()
.filter(|c| compatible_source(p, d, c.id))
.collect();
form = form.push(picker(
choices,
d,
0,
d.target,
"Choose existing source…",
Event::Target,
));
}
if d.tool != Tool::Connect {
form = form.push(
text_input("Contribution name (defaults to source)", &d.name)
.on_input(|v| event(Event::Name(v)))
.padding(8)
.size(12),
);
}
if matches!(d.tool, Tool::Control | Tool::Branch) {
let kinds = if d.tool == Tool::Branch {
vec![ControlKind::Choice, ControlKind::Fork]
} else {
vec![
ControlKind::Choice,
ControlKind::Merge,
ControlKind::Fork,
ControlKind::Join,
ControlKind::Loop,
]
};
form = form.push(
pick_list(
kinds.into_iter().map(ControlChoice).collect::<Vec<_>>(),
Some(ControlChoice(d.control)),
|v| event(Event::Control(v)),
)
.padding(8)
.text_size(12),
);
}
if d.tool == Tool::Branch {
form = form.push(picker(
targets(p, owner, Tool::Work),
d,
1,
d.alternate,
"Second path work…",
Event::Alternate,
));
if d.control != ControlKind::Fork {
form = form
.push(
text_input("First path guard", &d.guard)
.on_input(|v| event(Event::Guard(v)))
.padding(8),
)
.push(
text_input("Second path guard (e.g. else)", &d.alternate_guard)
.on_input(|v| event(Event::AlternateGuard(v)))
.padding(8),
);
}
}
if d.fixed {
if let (Some(a), Some(b)) = (d.before, d.after) {
form = form.push(label(format!("{} → {}", p.named(a), p.named(b))));
}
} else {
form = form
.push(picker(
contributions(p, owner),
d,
2,
d.before,
"After contribution…",
Event::Before,
))
.push(button("Clear preceding").on_press(event(Event::ClearBefore)))
.push(picker(
contributions(p, owner),
d,
3,
d.after,
"Before contribution…",
Event::After,
))
.push(button("Clear following").on_press(event(Event::ClearAfter)));
}
if d.tool != Tool::Branch
&& d.insertion.link.is_none()
&& (d.before.is_some() || d.tool == Tool::Connect)
{
form = form.push(
text_input(
"Path condition (empty remains a Choice / Loop warning)",
&d.guard,
)
.on_input(|v| event(Event::Guard(v)))
.padding(8)
.size(12),
);
}
let valid = match d.command(p) {
Ok(_) => true,
Err(problem) => {
form = form.push(label(problem));
false
}
};
let actions = row![
button("Create").on_press_maybe(valid.then(|| event(Event::Submit))),
button("Cancel").on_press(event(Event::Cancel))
]
.spacing(7);
out = out.push(
container(
column![
actions,
scrollable(form).height((app.window_size.height - 520.).clamp(150., 260.))
]
.spacing(8),
)
.padding(12)
.style(style::card),
);
}
super::super::scenario_scroll::observe_topology(
context,
scenario::ScrollArea::Page,
scrollable(out.padding(18)),
)
}
+6 -5
View File
@@ -432,11 +432,12 @@ mod tests {
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
for (interface, item) in [(provided, report), (required, result)] {
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
}
p.relate(
RelationKind::InterfaceProvision,
+559
View File
@@ -0,0 +1,559 @@
//! One address for a carried value, shared by scenario creation and property editing.
use oa_core::*;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct FieldPathKey {
pub path: Vec<Id>,
pub field: Id,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FieldPathChoice {
pub key: FieldPathKey,
pub label: String,
pub picker_label: String,
}
impl std::fmt::Display for FieldPathChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.picker_label)
}
}
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|| match phase {
MessageKind::Call => matches!(
entry.direction,
ParameterDirection::Unspecified
| ParameterDirection::In
| ParameterDirection::InOut
),
MessageKind::Reply => matches!(
entry.direction,
ParameterDirection::Unspecified
| ParameterDirection::Out
| ParameterDirection::InOut
| ParameterDirection::Return
),
_ => true,
}
}
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
let qualified = entry
.path
.iter()
.chain(std::iter::once(&entry.field))
.map(|id| {
project
.elements
.get(id)
.map(|e| format!("{} (@{})", e.name, e.alias))
.unwrap_or_else(|| id.to_string())
})
.collect::<Vec<_>>();
let direction =
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
match entry.direction {
ParameterDirection::Unspecified => " · direction unspecified — review",
ParameterDirection::In => " · In",
ParameterDirection::Out => " · Out",
ParameterDirection::InOut => " · InOut",
ParameterDirection::Return => " · Return",
}
} else {
""
};
let terminal = qualified
.last()
.expect("A field path always has a terminal");
let owner_path = qualified[..qualified.len() - 1].join(" → ");
let picker_label = format!(
"{terminal}{direction} — {}{}",
project.named(entry.item),
if owner_path.is_empty() {
String::new()
} else {
format!(" / {owner_path}")
},
);
FieldPathChoice {
key: FieldPathKey {
path: entry.path,
field: entry.field,
},
label: format!(
"{} / {}{direction}",
project.named(entry.item),
qualified.join(" → ")
),
picker_label,
}
}
// A bounded picker must expose its distinguishing field before a long path.
// Keep the full address separately for the wrapping detail text.
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
choices
}
#[cfg(test)]
pub fn choices(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
) -> Result<Vec<FieldPathChoice>, String> {
Ok(sorted_choices(
data::try_carried_field_paths(project, exchange)?
.into_iter()
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
.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();
sorted_choices(
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(),
)
}
/// Browse one record at a time, including explicitly chosen recursive hops.
pub fn at_scope(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
scope: Option<&FieldPathKey>,
) -> Result<Vec<FieldPathChoice>, String> {
match scope {
Some(key) => children(project, exchange, phase, key),
None => Ok(roots(project, exchange, phase)),
}
}
/// 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(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
key: &FieldPathKey,
) -> Option<FieldPathChoice> {
data::carried_value_path(project, exchange, &key.path, key.field)
.filter(|entry| phase_allows(project, entry, phase))
.map(|entry| named_choice(project, entry))
}
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
let (&field, path) = key.path.split_last()?;
Some(FieldPathKey {
path: path.to_vec(),
field,
})
}
/// Expand exactly one selected record. This keeps recursive authoring explicit
/// and finite, and never traverses Reference storage or manufactures value paths.
pub fn children(
project: &Project,
exchange: &Relation,
phase: &MessageKind,
key: &FieldPathKey,
) -> Result<Vec<FieldPathChoice>, String> {
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,
..
}) = project.elements.get(&key.field).and_then(Element::shared)
else {
return Ok(vec![]);
};
if !data::is_record(project, *record) {
return Ok(vec![]);
}
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
return Err(format!(
"A value path can contain at most {} enclosing fields.",
data::MAX_VALUE_PATH_DEPTH
));
}
let mut path = key.path.clone();
path.push(key.field);
Ok(sorted_choices(
data::effective_fields(project, *record)
.into_iter()
.map(|field| {
named_choice(
project,
data::CarriedFieldPath {
item: entry.item,
path: path.clone(),
field: field.id,
direction: entry.direction,
},
)
})
.collect(),
))
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub struct Payload {
pub project: Project,
pub exchange: Id,
pub item: Id,
pub leaf: Id,
pub origin: Id,
pub destination: Id,
pub scenario: Id,
}
pub fn payload() -> Payload {
let mut p = Project::empty();
let item = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Operation,
supertype: None,
};
let record = p
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
.unwrap();
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
let leaf = p
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
.unwrap();
let origin = p
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
.unwrap();
let destination = p
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
.unwrap();
for (id, type_ref, direction) in [
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
(origin, TypeRef::Named(record), ParameterDirection::In),
(destination, TypeRef::Named(record), ParameterDirection::Out),
] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref,
direction,
minimum: 1,
maximum: Some(1),
unit: String::new(),
storage: FieldStorage::Value,
default: None,
};
}
let source = p
.add_element(Kind::Activity, Layer::Oa, "Request", None)
.unwrap();
let target = p
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
.unwrap();
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
source,
target,
items: vec![item],
};
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
.unwrap();
validate_candidate(&p).unwrap();
Payload {
project: p,
exchange,
item,
leaf,
origin,
destination,
scenario,
}
}
#[test]
fn phase_choices_keep_reused_record_addresses_distinct() {
let mut f = payload();
assert_eq!(
data::item_mechanism(&f.project, f.item),
Some(ExchangeMechanism::Operation)
);
let call = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
assert!(
call.iter()
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
);
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
let reply = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Reply,
)
.unwrap();
assert!(
reply
.iter()
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
);
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
if let Some(SharedBody::DataField { direction, .. }) = f
.project
.elements
.get_mut(&f.destination)
.unwrap()
.shared_mut()
{
*direction = ParameterDirection::InOut;
}
let both = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
let leaves = both
.iter()
.filter(|c| c.key.field == f.leaf)
.collect::<Vec<_>>();
assert_eq!(leaves.len(), 2);
assert_ne!(leaves[0].key, leaves[1].key);
assert_ne!(leaves[0].label, leaves[1].label);
let values = leaves
.into_iter()
.map(|c| (c.key.clone(), "1".to_string()))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(values.len(), 2);
}
#[test]
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
let mut f = payload();
if let Some(SharedBody::DataField {
storage, direction, ..
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
{
*storage = FieldStorage::Reference;
*direction = ParameterDirection::Unspecified;
}
let call = choices(
&f.project,
&f.project.relations[&f.exchange],
&MessageKind::Call,
)
.unwrap();
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
assert!(direct.key.path.is_empty());
assert!(direct.label.contains("unspecified"));
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
}
pub fn recursive_payload() -> (Payload, Id) {
let mut f = payload();
let record = f.project.elements[&f.leaf].parent.unwrap();
let next = f
.project
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
.unwrap();
*f.project
.elements
.get_mut(&next)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(record),
minimum: 0,
maximum: Some(1),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
};
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();
let exchange = &f.project.relations[&f.exchange];
let record = FieldPathKey {
path: vec![f.origin],
field: next,
};
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
assert_eq!(leaf.key.path, vec![f.origin, next]);
assert!(
!choices(&f.project, exchange, &MessageKind::Call)
.unwrap()
.iter()
.any(|c| c.key == leaf.key)
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
assert_eq!(parent(&leaf.key), Some(record));
let deepest = FieldPathKey {
path: std::iter::once(f.origin)
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
.collect(),
field: next,
};
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
assert!(
children(&f.project, exchange, &MessageKind::Call, &deepest)
.unwrap_err()
.contains("128")
);
}
#[test]
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
let (f, next) = recursive_payload();
let exchange = &f.project.relations[&f.exchange];
let context = FieldPathKey {
path: vec![f.origin, next, next],
field: next,
};
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
let leaf = visible
.iter()
.find(|choice| choice.key.field == f.leaf)
.unwrap();
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
assert!(
visible.windows(2).all(
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
)
);
assert_ne!(
visible[0].to_string().chars().take(24).collect::<String>(),
visible[1].to_string().chars().take(24).collect::<String>()
);
assert_eq!(
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
Some(leaf.clone())
);
}
}
File diff suppressed because it is too large Load Diff
+181 -1
View File
@@ -292,6 +292,7 @@ impl DiagramCanvas<'_> {
request.placement = Some(node);
} else if let Some(edge) = self
.hit_label(&scene.routed, local)
.or_else(|| self.hit_association_label(&scene.routed, local))
.or_else(|| self.hit_edge(&scene.routed, local).map(|(id, _)| id))
{
request.object = edge;
@@ -369,6 +370,33 @@ impl DiagramCanvas<'_> {
})
.map(|edge| edge.id)
}
fn hit_association_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let p = self.world(p);
routed
.edges
.values()
.flat_map(|edge| {
self.project
.relations
.get(&edge.id)
.into_iter()
.flat_map(move |relation| routing::association_end_geometry(relation, edge))
.map(move |end| (edge.id, end.label))
})
.filter(|(_, label)| {
Rectangle::new(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
)
.contains(p)
})
.min_by(|a, b| {
p.distance(point(a.1.center))
.total_cmp(&p.distance(point(b.1.center)))
.then(a.0.cmp(&b.0))
})
.map(|(id, _)| id)
}
fn preview_diagram(&self, state: &State) -> Diagram {
let mut diagram = self.project.diagrams[&self.diagram].clone();
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
@@ -725,7 +753,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
state.drag = Some((id, p, position));
state.preview = Some(position);
Some(Action::publish(Message::SelectNode(id)).and_capture())
} else if let Some(id) = self.hit_label(&scene.routed, p) {
} else if let Some(id) = self
.hit_label(&scene.routed, p)
.or_else(|| self.hit_association_label(&scene.routed, p))
{
Some(Action::publish(Message::Select(id)).and_capture())
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
if id == self.selected
@@ -1010,6 +1041,37 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
..Default::default()
});
}
if let Some(relation) = self.project.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, routed) {
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
f.stroke(
&Path::new(|p| {
p.move_to(a);
p.line_to(tip);
p.line_to(b);
}),
Stroke::default().with_color(color).with_width(1.7),
);
}
let label = &end.label;
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
f.fill_text(Text {
content: label.text.clone(),
position: point(label.center),
color: if active { style::TEAL } else { style::MUTED },
size: 13.into(),
align_x: iced::widget::text::Alignment::Center,
align_y: iced::alignment::Vertical::Center,
max_width: f32::INFINITY,
..Default::default()
});
}
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
@@ -1257,6 +1319,17 @@ fn endpoint_position(view: &ProjectedView, id: Id) -> Option<Position> {
})
})
}
fn navigation_mark(tip: Position, inward: Position) -> [Point; 3] {
let tip = point(tip);
let delta = point(inward) - tip;
let direction = delta / delta.x.hypot(delta.y).max(0.001);
let normal = Vector::new(-direction.y, direction.x) * 4.;
[
tip + direction * 9. + normal,
tip,
tip + direction * 9. - normal,
]
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PortGlyph {
Function,
@@ -1544,6 +1617,21 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
if !label.text.is_empty() {
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
if let Some(relation) = p.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, route) {
let side = if end.source_end { "source" } else { "target" };
svg.push_str(&format!(
"<g data-association=\"{}\" data-end=\"{side}\">",
edge.id
));
if end.navigable {
let [a, tip, b] = navigation_mark(end.tip, end.inward);
svg.push_str(&format!("<path data-navigation=\"{side}\" d=\"M {} {} L {} {} L {} {}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.7\"/>",a.x,a.y,tip.x,tip.y,b.x,b.y));
}
let label = &end.label;
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text></g>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
}
}
for n in &view.nodes {
if !containers.contains(&n.id) {
@@ -2353,6 +2441,98 @@ mod tests {
assert!(geometry.bounds.position.y <= route.label.bounds.position.y);
}
#[test]
fn association_end_contracts_share_svg_geometry_and_select_without_moving_name_label() {
for self_loop in [false, true] {
let mut p = Project::empty();
let a = p.add(Kind::DataType, "Tree", 100., 100.);
let b = if self_loop {
a
} else {
p.add(Kind::DataType, "Branch", 650., 100.)
};
for id in BTreeSet::from([a, b]) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
};
}
let edge = p.relate(RelationKind::DataAssociation, "Hierarchy", a, b);
p.relations.get_mut(&edge).unwrap().body = RelationBody::DataAssociation {
source: a,
target: b,
source_role: "parent".into(),
target_role: "children".into(),
source_minimum: 0,
source_maximum: Some(1),
target_minimum: 0,
target_maximum: None,
source_navigable: false,
target_navigable: true,
};
let diagram = p.ensure_diagram(Layer::Oa);
let projected = project_view(&p, diagram);
assert!(
!projected
.edges
.iter()
.find(|e| e.id == edge)
.unwrap()
.oriented
);
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
assert_eq!(ends.len(), 2);
let svg = export_svg(&p, diagram);
assert!(svg.contains("parent [0..1]</text>"));
assert!(svg.contains("children [0..*]</text>"));
assert!(!svg.contains("data-navigation=\"source\""));
assert!(svg.contains("data-navigation=\"target\""));
let mut canvas = DiagramCanvas {
project: &p,
diagram,
selected: edge,
zoom: 0.7,
pan: Vector::new(350., 350.),
context: Context {
document: p.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
editable: true,
connection: None,
};
for end in ends {
assert!(svg.contains(&format!(
"x=\"{}\" y=\"{}\" text-anchor",
end.label.center.x, end.label.center.y
)));
let at = Point::new(
end.label.center.x * canvas.zoom + canvas.pan.x,
end.label.center.y * canvas.zoom + canvas.pan.y,
);
let mut state = State::default();
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
assert!(
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
);
assert!(
state.route_drag.is_none(),
"Endpoint contract labels never drag the association name"
);
assert_eq!(
canvas
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
.object,
edge
);
canvas.selected = p.id;
}
}
}
#[test]
fn detached_label_selects_connection_and_occluding_card_owns_its_hit() {
let (mut p, diagram, edge) = route_fixture();
p.diagrams.get_mut(&diagram).unwrap().routes[0].label = Some(EdgeLabel {
+142 -13
View File
@@ -14,6 +14,7 @@ use iced::{
use oa_core::*;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
mod data_fields;
mod port_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -31,6 +32,12 @@ pub struct Input {
pub raw: String,
pub editor: text_editor::Content,
}
#[derive(Clone, Default)]
pub struct ValueBrowser {
pub scope: Option<crate::data_values::FieldPathKey>,
pub page: usize,
}
pub struct Draft {
pub kind: RecordKind,
pub original: Id,
@@ -39,6 +46,7 @@ pub struct Draft {
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
}
impl Draft {
@@ -76,11 +84,14 @@ impl Draft {
inputs: BTreeMap::new(),
exact_strings: false,
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
};
draft.rebuild();
Some(draft)
}
fn rebuild(&mut self) {
// Array/variant edits can change the meaning of a row path.
self.value_browsers.clear();
self.inputs.clear();
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
match value {
@@ -218,7 +229,7 @@ impl Draft {
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"field": Id::nil(), "expression": ""})
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") {
@@ -242,6 +253,33 @@ impl Draft {
self.rebuild();
Ok(())
}
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
let mut candidate = self.materialize()?;
candidate
.pointer_mut(path)
.and_then(Value::as_array_mut)
.ok_or("List no longer exists.")?
.push(value);
self.value = candidate;
self.rebuild();
Ok(())
}
pub fn value_path(
&mut self,
path: &str,
key: crate::data_values::FieldPathKey,
) -> Result<(), String> {
let mut candidate = self.materialize()?;
let row = candidate
.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.value = candidate;
self.rebuild();
Ok(())
}
pub fn command(&self) -> Result<Command, String> {
let mut value = self.materialize()?;
let decode = |e: serde_json::Error| e.to_string();
@@ -411,6 +449,7 @@ fn reference(path: &str) -> bool {
| "Named"
| "diagram"
| "machine"
| "supertype"
) || matches!(
parent,
"items"
@@ -440,6 +479,7 @@ fn optional_reference(path: &str) -> bool {
| "reply_to"
| "lifeline"
| "state"
| "supertype"
)
}
fn multiline(path: &str) -> bool {
@@ -462,9 +502,11 @@ fn free_string(path: &str) -> bool {
multiline(path)
|| matches!(
path.rsplit('/').next(),
Some("name" | "uri" | "revision" | "fragment" | "unit")
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
)
|| path.split('/').rev().nth(1) == Some("literals")
|| (path.contains("/default/")
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
}
// These are choices for the closed model, not a second validation boundary.
@@ -662,21 +704,42 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
.collect();
}
if field == "field" {
let address_path = path
.strip_suffix("/field")
.and_then(|row| value.pointer(&format!("{row}/path")))
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
.unwrap_or_default();
return element
.as_ref()
.and_then(|e| match e.shared()? {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id), ..
exchange: Some(id),
message_kind,
..
},
..
} => project.relations.get(id),
} => project.relations.get(id).and_then(|r| {
let scope = address_path.split_last().map(|(&field, path)| {
crate::data_values::FieldPathKey {
path: path.to_vec(),
field,
}
});
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
}),
_ => None,
})
.map(|r| validation::carried_fields(project, r).into_iter().collect())
.unwrap_or_default();
.unwrap_or_default()
.into_iter()
.filter(|choice| choice.key.path == address_path)
.map(|choice| choice.key.field)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
}
let scenario = element
.as_ref()
.and_then(|e| e.parent)
@@ -707,6 +770,40 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
return false;
}
match field {
"supertype" => element.as_ref().is_some_and(|owner| {
if candidate.id == owner.id
|| candidate.kind() != owner.kind()
|| (candidate.kind() == Kind::DataType
&& !data::is_record(project, candidate.id))
{
return false;
}
if let (
Some(SharedBody::ExchangeItem {
kind, mechanism, ..
}),
Some(SharedBody::ExchangeItem {
kind: base_kind,
mechanism: base_mechanism,
..
}),
) = (owner.shared(), candidate.shared())
&& (kind != base_kind
|| (*base_mechanism != ExchangeMechanism::Unspecified
&& mechanism != base_mechanism))
{
return false;
}
let mut next = Some(candidate.id);
let mut seen = BTreeSet::new();
while let Some(id) = next {
if id == owner.id || !seen.insert(id) {
return false;
}
next = project.elements.get(&id).and_then(data::supertype);
}
true
}),
"machine" => candidate.kind() == Kind::StateMachine,
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original
@@ -1058,6 +1155,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
let words: &[&str] = match field {
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
"layer" => &["Oa", "Sa"],
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
"storage" => &["Value", "Reference"],
"direction" if path.contains("DataField") => {
&["Unspecified", "In", "Out", "InOut", "Return"]
}
"transmission" => &[
"Unspecified",
"Synchronous",
"Asynchronous",
"Unicast",
"Multicast",
"Broadcast",
],
"acquisition" => &["Unspecified", "Read", "Accept"],
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
"viewpoint" if path.contains("/Oa/") => &[
"OperationalActivity",
@@ -1066,7 +1177,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
],
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
"phase" => &["Entry", "Do", "Exit"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
"kind" if path.contains("State") => &[
"State",
@@ -1132,11 +1243,20 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
if field == "definition" {
return vec![
("Boolean".into(), json!("Boolean")),
("Integer".into(), json!("Integer")),
("Real".into(), json!("Real")),
("Text".into(), json!("String")),
(
"Integer".into(),
json!({"Integer":{"minimum":null,"maximum":null}}),
),
(
"Real".into(),
json!({"Real":{"minimum":null,"maximum":null}}),
),
(
"Text".into(),
json!({"String":{"minimum_length":0,"maximum_length":null}}),
),
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
("Record".into(), json!("Record")),
("Record".into(), json!({"Record":{"supertype":null}})),
];
}
if field == "type_ref" {
@@ -1273,6 +1393,9 @@ fn property<'a>(
label: &str,
skip_variant: bool,
) -> View<'a, Message> {
if let Some(view) = data_fields::property(draft, project, value, path) {
return view;
}
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
let mut content = column![].spacing(4);
@@ -2075,6 +2198,8 @@ mod tests {
Layer::Oa,
SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
},
);
let mut draft = Draft::from_project(&p, item).unwrap();
@@ -2091,8 +2216,12 @@ mod tests {
let interface = p
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
.unwrap();
p.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
assert!(draft.dirty());
assert_eq!(p.elements[&item].layer(), Layer::Oa);
+813
View File
@@ -0,0 +1,813 @@
//! Typed data contracts use the same draft and command boundary as other properties.
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()))
.ok()
.or_else(|| project.elements.get(&draft.original).cloned())
}
fn definition(project: &Project, reference: &TypeRef) -> Option<DataTypeDefinition> {
Some(match reference {
TypeRef::Boolean => DataTypeDefinition::Boolean,
TypeRef::Integer => DataTypeDefinition::Integer {
minimum: None,
maximum: None,
},
TypeRef::Real => DataTypeDefinition::Real {
minimum: None,
maximum: None,
},
TypeRef::String => DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
TypeRef::Named(id) => match project.elements.get(id)?.shared()? {
SharedBody::DataType { definition, .. } => definition.clone(),
_ => return None,
},
})
}
fn literal(definition: &DataTypeDefinition) -> Option<Value> {
Some(match definition {
DataTypeDefinition::Boolean => json!({"Boolean": false}),
DataTypeDefinition::Integer { minimum, .. } => json!({"Integer": minimum.unwrap_or(0)}),
DataTypeDefinition::Real { minimum, .. } => json!({"Real": minimum.unwrap_or(0.)}),
DataTypeDefinition::String { .. } => json!({"String": ""}),
DataTypeDefinition::Enumeration { literals } => json!({"Enumeration": literals.first()?}),
DataTypeDefinition::Record { .. } => return None,
})
}
pub(super) fn property<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
) -> Option<View<'a, Message>> {
if path.split('/').rev().nth(1) == Some("values") && value.is_object() {
return Some(message_value(draft, project, value, path));
}
let leaf = path.rsplit('/').next()?;
if leaf == "direction" && path.contains("/DataField/") {
let operation = element(draft, project)
.and_then(|e| e.parent)
.and_then(|id| data::item_mechanism(project, id))
== Some(ExchangeMechanism::Operation);
if !operation {
let mut out = column![
text("Parameter direction").size(11).color(style::MUTED),
text("Unspecified · direction applies to direct Operation parameters.").size(11)
]
.spacing(5);
if value.as_str() != Some("Unspecified") {
out = out
.push(
text(format!(
"Current {:?} requires review.",
value.as_str().unwrap_or_default()
))
.size(11),
)
.push(
button(text("Use Unspecified").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), json!("Unspecified"))),
);
}
return Some(out.into());
}
}
let bounded = (path.contains("/definition/Integer/") || path.contains("/definition/Real/"))
&& matches!(leaf, "minimum" | "maximum");
let count_max = matches!(leaf, "maximum_length" | "source_maximum" | "target_maximum")
|| (leaf == "maximum" && path.contains("/DataField/"));
if bounded || count_max {
let label = match leaf {
"minimum" => "Inclusive lower bound",
"maximum" if bounded => "Inclusive upper bound",
"maximum_length" => "Maximum Unicode characters",
"source_maximum" => "Source maximum count",
"target_maximum" => "Target maximum count",
_ => "Maximum count",
};
let mut out = column![text(label).size(11).color(style::MUTED)].spacing(5);
if value.is_null() {
out = out.push(
button(
text(if bounded {
"Unbounded · set bound"
} else {
"Unlimited · set maximum"
})
.size(12),
)
.style(style::nav)
.on_press(Message::FieldShape(
path.into(),
if path.contains("/Real/") {
json!(0.)
} else if count_max {
let minimum_path = path.replace("maximum", "minimum");
json!(
draft
.materialize()
.ok()
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
.unwrap_or(1)
.max(1)
)
} else {
json!(0)
},
)),
);
} else if let Some(input) = draft.inputs.get(path) {
let owned = path.to_owned();
out = out
.push(
text_input("", &input.raw)
.on_input(move |s| Message::Field(owned.clone(), s))
.size(12)
.padding(8),
)
.push(
button(text("Clear bound").size(10))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
}
return Some(out.into());
}
if leaf == "default" && (path.contains("/DataField/") || path.contains("/DataType/")) {
let owner = element(draft, project);
let (definition, collection, reference, minimum) =
match owner.as_ref().and_then(Element::shared) {
Some(SharedBody::DataType { definition, .. }) => {
(Some(definition.clone()), false, false, 0)
}
Some(SharedBody::DataField {
type_ref,
storage,
minimum,
..
}) => (
definition(project, type_ref),
true,
*storage == FieldStorage::Reference,
*minimum,
),
_ => (None, false, false, 0),
};
let initial = definition.as_ref().and_then(literal);
let mut out = column![text("Default").size(11).color(style::MUTED)].spacing(6);
if reference {
out = out.push(
text("References have no literal default; they identify an existing record.")
.size(11)
.color(style::MUTED),
);
}
if value.is_null() {
out = out.push(text("Absent · no authored default").size(12));
if !reference {
if let Some(initial) = initial {
out = out.push(
button(text("Set literal default").size(11))
.style(style::nav)
.on_press(Message::FieldShape(
path.into(),
if collection {
json!([initial])
} else {
initial
},
)),
);
}
if collection && minimum == 0 {
out = out.push(
button(text("Set explicit empty collection").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), json!([]))),
);
}
}
} else {
out = out.push(
button(text("Clear default · make absent").size(11))
.style(style::nav)
.on_press(Message::FieldShape(path.into(), Value::Null)),
);
if let Some(values) = value.as_array() {
if values.is_empty() {
out = out.push(text("Explicit empty collection []").size(12));
}
for (i, value) in values.iter().enumerate() {
out = out
.push(literal_editor(
draft,
project,
value,
&format!("{path}/{i}"),
definition.as_ref(),
))
.push(
button(text("Remove value").size(10))
.style(style::nav)
.on_press(Message::FieldArray(path.into(), Some(i), 0)),
);
}
if let Some(initial) = initial
&& !reference
{
out = out.push(
button(text("+ Add literal").size(11))
.style(style::nav)
.on_press(Message::FieldAppend(path.into(), initial)),
);
}
} else {
out = out.push(literal_editor(
draft,
project,
value,
path,
definition.as_ref(),
));
}
}
return Some(out.into());
}
None
}
fn literal_editor<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
definition: Option<&DataTypeDefinition>,
) -> View<'a, Message> {
if let Some(DataTypeDefinition::Enumeration { literals }) = definition {
let current = draft
.materialize()
.ok()
.and_then(|v| {
v.pointer(&format!("{path}/Enumeration"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.or_else(|| {
value
.get("Enumeration")
.and_then(Value::as_str)
.map(str::to_owned)
});
let mut choices = literals.clone();
if let Some(current) = &current
&& !choices.contains(current)
{
choices.push(current.clone());
}
let path = path.to_owned();
let picker = pick_list(choices, current, move |s| {
Message::FieldShape(path.clone(), json!({"Enumeration": s}))
})
.text_size(12)
.padding(8)
.width(Fill);
return if value.get("Enumeration").is_none() {
column![text("Current default uses another literal type. Choose an enumeration literal or clear the default.").size(11).color(style::MUTED),picker].spacing(5).into()
} else {
picker.into()
};
}
let mut out = column![].spacing(5);
if let Some(fields) = value.as_object() {
for (tag, payload) in fields {
out = out.push(super::property(
draft,
project,
payload,
&format!("{path}/{tag}"),
&friendly(tag),
true,
));
}
} else {
out = out.push(
text("Current literal requires review; clear it to choose a typed value.").size(11),
);
}
out.into()
}
fn message_value<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
path: &str,
) -> View<'a, Message> {
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
let row = edited.pointer(path).unwrap_or(value).clone();
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
let owner = element(draft, project);
let context = owner
.as_ref()
.and_then(Element::shared)
.and_then(|body| match body {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id),
message_kind,
..
},
..
} => project.relations.get(id).map(|r| (r, message_kind)),
_ => None,
});
let current_key = current
.as_ref()
.map(|value| crate::data_values::FieldPathKey {
path: value.path.clone(),
field: value.field,
});
let selected = context.and_then(|(exchange, phase)| {
current_key
.as_ref()
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
});
let browser = draft
.value_browsers
.get(path)
.cloned()
.unwrap_or_else(|| ValueBrowser {
scope: selected
.as_ref()
.and_then(|choice| crate::data_values::parent(&choice.key)),
page: 0,
});
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
if let Some(choice) = &selected {
out = out.push(text(choice.label.clone()).size(11));
} else if let Some(key) = &current_key
&& !key.field.is_nil()
{
out = out.push(
text(format!(
"Current address needs review: {} / {}. Choose an explicit field.",
key.path
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(" → "),
project.named(key.field)
))
.size(11)
.color(style::MUTED),
);
}
if let Some((exchange, phase)) = context {
let location = browser
.scope
.as_ref()
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
.map(|choice| choice.label)
.unwrap_or_else(|| "Root carried fields".into());
out = out.push(
text(format!("Browse: {location}"))
.size(11)
.color(style::MUTED),
);
if let Some(scope) = &browser.scope {
let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press(
Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0)
)]
.spacing(4);
if !scope.path.is_empty() {
navigation = navigation.push(
button(text("Root fields").size(11))
.style(style::nav)
.on_press(Message::FieldValueBrowse(path.into(), None, 0)),
);
}
out = out.push(navigation);
}
match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) {
Ok(choices) => {
let count = choices.len();
let (visible, page, pages) = field_page(choices, browser.page);
// Selection detail stays visible independently of browser location/page.
let visible_selection = selected
.clone()
.filter(|choice| visible.iter().any(|c| c.key == choice.key));
let owned = path.to_owned();
out = out.push(
pick_list(visible, visible_selection, move |choice| {
Message::FieldValuePath(owned.clone(), choice.key)
})
.placeholder("Choose a field here…")
.text_size(12)
.padding(8)
.width(Fill),
);
if pages > 1 {
out = out.push(
row![
button(text("Previous").size(11))
.style(style::nav)
.on_press_maybe((page > 0).then(|| Message::FieldValueBrowse(
path.into(),
browser.scope.clone(),
page - 1
))),
text(format!("{} / {pages} · {count} fields", page + 1)).size(11),
button(text("Next").size(11))
.style(style::nav)
.on_press_maybe((page + 1 < pages).then(|| {
Message::FieldValueBrowse(
path.into(),
browser.scope.clone(),
page + 1,
)
}))
]
.spacing(4)
.align_y(iced::Alignment::Center),
);
} else if count == 0 {
out = out.push(
text("No compatible fields at this location.")
.size(11)
.color(style::MUTED),
);
}
}
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
}
if let Some(choice) = &selected
&& browser.scope.as_ref() != Some(&choice.key)
&& crate::data_values::children(project, exchange, phase, &choice.key)
.is_ok_and(|children| !children.is_empty())
{
out = out.push(
button(
text(format!(
"Browse fields in {} →",
project.named(choice.key.field)
))
.size(11),
)
.style(style::nav)
.on_press(Message::FieldValueBrowse(
path.into(),
Some(choice.key.clone()),
0,
)),
);
}
} else {
out = out.push(
text("Choose a message exchange to browse its carried fields.")
.size(11)
.color(style::MUTED),
);
}
if let Some(expression) = value.get("expression") {
out = out.push(super::property(
draft,
project,
expression,
&format!("{path}/expression"),
"Value expression",
false,
));
}
out.into()
}
const FIELDS_PER_PAGE: usize = 24;
fn field_page(
choices: Vec<crate::data_values::FieldPathChoice>,
requested: usize,
) -> (Vec<crate::data_values::FieldPathChoice>, usize, usize) {
let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1);
let page = requested.min(pages - 1);
(
choices
.into_iter()
.skip(page * FIELDS_PER_PAGE)
.take(FIELDS_PER_PAGE)
.collect(),
page,
pages,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
let (mut f, next) = crate::data_values::tests::recursive_payload();
if let Some(SharedBody::DataField { direction, .. }) = f
.project
.elements
.get_mut(&f.destination)
.unwrap()
.shared_mut()
{
*direction = ParameterDirection::InOut;
}
let call = f
.project
.add_element(Kind::Occurrence, Layer::Oa, "Call", Some(f.scenario))
.unwrap();
*f.project
.elements
.get_mut(&call)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Call,
reply_to: None,
values: vec![MessageValue {
path: vec![f.origin],
field: f.leaf,
expression: "old".into(),
}],
},
precondition: String::new(),
postcondition: String::new(),
};
f.project
.elements
.get_mut(&f.scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(call);
let mut app = crate::app::App::with_project(f.project.clone());
let path = "/body/Oa/Shared/Occurrence/body/Exchange/values/0";
let _ = app.update(Message::Select(call));
let _ = app.update(Message::Field(
format!("{path}/expression"),
"newly typed".into(),
));
let before_browse = app.draft.as_ref().unwrap().materialize().unwrap();
for (scope, page) in [
(None, 0),
(
Some(crate::data_values::FieldPathKey {
path: vec![f.origin],
field: next,
}),
999,
),
] {
let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page));
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
assert_eq!(
app.draft.as_ref().unwrap().materialize().unwrap(),
before_browse
);
assert_eq!(app.project(), &f.project);
}
let _ = app.update(Message::FieldValuePath(
path.into(),
crate::data_values::FieldPathKey {
path: vec![f.destination],
field: f.leaf,
},
));
let _ = app.update(Message::Apply);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&call].shared()
else {
panic!()
};
assert_eq!(
values,
&vec![MessageValue {
path: vec![f.destination],
field: f.leaf,
expression: "newly typed".into()
}],
"{}",
app.status
);
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &f.project);
let nested = crate::data_values::children(
app.project(),
&app.project().relations[&f.exchange],
&MessageKind::Call,
&crate::data_values::FieldPathKey {
path: vec![f.origin],
field: next,
},
)
.unwrap()
.into_iter()
.find(|c| c.key.field == f.leaf)
.unwrap();
let _ = app.update(Message::Select(call));
let _ = app.update(Message::FieldValuePath(path.into(), nested.key.clone()));
let _ = app.update(Message::Apply);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = app.project().elements[&call].shared()
else {
panic!()
};
assert_eq!(values[0].path, vec![f.origin, next], "{}", app.status);
assert!(
crate::data_values::resolve(
app.project(),
&app.project().relations[&f.exchange],
&MessageKind::Call,
&nested.key
)
.is_some()
);
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
}
#[test]
fn field_browser_pages_every_direct_field_without_flattening_other_records() {
let f = crate::data_values::tests::wide_payload();
let exchange = &f.project.relations[&f.exchange];
let roots =
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap();
assert!(roots.len() > FIELDS_PER_PAGE);
assert!(roots.iter().all(|choice| choice.key.path.is_empty()));
let scope = crate::data_values::FieldPathKey {
path: vec![],
field: f.origin,
};
let children =
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope))
.unwrap();
assert!(children.len() > FIELDS_PER_PAGE);
assert!(
children
.iter()
.all(|choice| choice.key.path == vec![f.origin])
);
for choices in [roots, children] {
let (_, _, pages) = field_page(choices.clone(), 0);
let visited: Vec<_> = (0..pages)
.flat_map(|page| {
let (visible, actual, _) = field_page(choices.clone(), page);
assert_eq!(actual, page);
assert!(visible.len() <= FIELDS_PER_PAGE);
visible
})
.collect();
assert_eq!(visited, choices);
assert_eq!(field_page(choices, usize::MAX).1, pages - 1);
}
assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1));
}
#[test]
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
let mut p = Project::empty();
let id = p
.add_element(Kind::DataType, Layer::Oa, "Counter", None)
.unwrap();
let mut draft = Draft::from_project(&p, id).unwrap();
let definition = "/body/Oa/Shared/DataType/definition";
draft
.shape(
definition,
json!({"Integer":{"minimum":null,"maximum":i64::MAX}}),
)
.unwrap();
let maximum = format!("{definition}/Integer/maximum");
draft.input(&maximum, "-".into());
assert!(draft.command().is_err());
assert!(draft.shape(&maximum, Value::Null).is_err());
assert_eq!(draft.inputs[&maximum].raw, "-");
draft.input(&maximum, "9223372036854775808".into());
assert!(draft.command().is_err());
draft.input(&maximum, i64::MAX.to_string());
draft
.shape(
"/body/Oa/Shared/DataType/default",
json!({"Integer":i64::MAX}),
)
.unwrap();
let accepted = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
assert!(matches!(
accepted.elements[&id].shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Integer {
maximum: Some(i64::MAX),
..
},
default: Some(DataValue::Integer(i64::MAX)),
..
})
));
draft.input(&maximum, "10".into());
assert!(execute_batch(&accepted, &[draft.command().unwrap()]).is_err());
assert_eq!(draft.inputs[&maximum].raw, "10");
}
#[test]
fn defaults_distinguish_absent_empty_and_typed_literals_with_escaped_unicode() {
let mut p = Project::empty();
let item = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Payload", None)
.unwrap();
let id = p
.add_element(Kind::DataField, Layer::Oa, "Optional names", Some(item))
.unwrap();
let mut draft = Draft::from_project(&p, id).unwrap();
let root = "/body/Oa/Shared/DataField";
draft.shape(&format!("{root}/minimum"), json!(0)).unwrap();
draft
.shape(&format!("{root}/maximum"), Value::Null)
.unwrap();
draft.shape(&format!("{root}/default"), json!([])).unwrap();
let empty = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
assert!(
matches!(empty.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v.is_empty())
);
draft
.shape(&format!("{root}/default"), Value::Null)
.unwrap();
let absent = execute_batch(&empty, &[draft.command().unwrap()]).unwrap();
assert!(matches!(
absent.elements[&id].shared(),
Some(SharedBody::DataField { default: None, .. })
));
draft
.shape(&format!("{root}/default"), json!([{"String":"é🙂\n"}]))
.unwrap();
draft.input(&format!("{root}/default/0/String"), "é🙂\n".into());
draft
.append(&format!("{root}/default"), json!({"String":"next"}))
.unwrap();
assert_eq!(
draft
.materialize()
.unwrap()
.pointer(&format!("{root}/default/0/String")),
Some(&json!("é🙂\n"))
);
draft.array(&format!("{root}/default"), Some(1), 0).unwrap();
draft.set_exact(true).unwrap();
let saved = execute_batch(&absent, &[draft.command().unwrap()]).unwrap();
assert!(
matches!(saved.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v == &vec![DataValue::String("é🙂\n".into())])
);
let _ = super::super::view(&draft, &saved);
draft
.shape(&format!("{root}/storage"), json!("Reference"))
.unwrap();
assert!(execute_batch(&saved, &[draft.command().unwrap()]).is_err());
}
#[test]
fn supertype_picker_excludes_descendants_and_keeps_original_inherited_field_identity() {
let mut p = Project::empty();
let base = p
.add_element(Kind::DataType, Layer::Oa, "Base", None)
.unwrap();
let child = p
.add_element(Kind::DataType, Layer::Oa, "Child", None)
.unwrap();
for (id, supertype) in [(base, None), (child, Some(base))] {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype },
default: None,
unit: String::new(),
};
}
let field = p
.add_element(Kind::DataField, Layer::Oa, "Owned by base", Some(base))
.unwrap();
let draft = Draft::from_project(&p, base).unwrap();
let path = "/body/Oa/Shared/DataType/definition/Record/supertype";
assert!(!reference_ids(&draft, &p, path).contains(&child));
let child_draft = Draft::from_project(&p, child).unwrap();
assert!(reference_ids(&child_draft, &p, path).contains(&base));
assert_eq!(data::effective_fields(&p, child)[0].id, field);
assert_eq!(p.elements[&field].parent, Some(base));
}
}
+7 -9
View File
@@ -144,13 +144,7 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
}
let declared: Vec<_> = interfaces
.iter()
.filter_map(|id| project.elements.get(id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.flat_map(|id| data::interface_items(project, *id))
.collect();
items_satisfy(project, items, &declared)
}
@@ -714,8 +708,12 @@ mod tests {
interface,
);
assert!(reference_ids(&draft, &project, target).is_empty());
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
}
+24 -7
View File
@@ -369,7 +369,7 @@ fn article(topic: Topic) -> Article {
&[
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
],
@@ -380,7 +380,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Use engineering payloads",
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
),
],
related: &[
@@ -510,17 +510,34 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
keywords: "data interface port direction input output inout return contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum bounds default unit specialization inheritance association storage reference recursive path transmission acquisition protocol",
sections: &[
walkthrough(
"Define a reusable payload",
&[
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
"Open Data & interfaces and create an Exchange item. Choose Information, Material or Energy for nature; choose Flow, Event, Operation or Shared data for mechanism. Keep Unspecified when the engineering decision is still open.",
"Create a Data type for a reusable definition. Choose Boolean, Integer, Real, String, Enumeration or Record. Numeric bounds are inclusive; String bounds count Unicode characters. A typed default must satisfy those bounds or enumeration literals. Units annotate quantities without converting them.",
"Create fields within an Exchange item or Record type. Set Value type, minimum/maximum count, unit and optional default. A default can be absent, explicitly empty when minimum is zero, or a list of typed scalar values. A type default describes one element; it does not create a required collection.",
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
],
),
explain(
"Operation parameters and message values",
"An Operation's direct fields are parameters. In and InOut supply call values; Out, InOut and Return supply reply values, from the provider's perspective. There is at most one Return parameter. Qualified paths distinguish origin.latitude from destination.latitude when both use the same Coordinate record. Nested fields inherit the parameter direction. Message expressions are annotations, not evaluated values.",
),
explain(
"Reuse, ownership and references",
"A Record or Exchange item can specialize one compatible base. Inherited fields keep their original owner and identity; edit the base to change them. Value fields compose a payload. Reference fields refer to separately identified Record objects and stop payload expansion. Optional recursive values and reference cycles are valid; a mandatory value cycle warns that no finite complete value exists. Data associations describe named reference ends, multiplicities and navigation between Record types without creating contained fields.",
),
walkthrough(
"Allocate an item to interfaces",
&[
"Create an Interface and allocate an Exchange item. Select its allocation row to edit transmission, acquisition and order; Open item navigates to the shared payload definition.",
"Two interfaces may allocate the same item with different protocols. Attach CAD, code or document evidence to the allocation when it concerns that particular contract.",
"Removing an allocation keeps the shared item and other allocations. If a live boundary depends on it, first revise that boundary contract. Undo restores an accepted removal.",
"An Interface may specialize one base and inherit its allocations. Duplicate allocation of an already inherited item is rejected. A specialized item satisfies its base contract; a base item does not satisfy a more specific contract.",
],
),
walkthrough(
"Connect participant boundaries",
&[
@@ -723,7 +740,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Open and migration",
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
"Open validates a complete candidate before replacing the project. Versions 1, 2 and 3 migrate explicitly to version 4; Model checks shows preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source3 upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
),
],
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
+2
View File
@@ -4,6 +4,8 @@ mod behavior;
mod connection_edit;
mod connection_tool;
mod context_menu;
mod data_values;
mod data_view;
mod diagram;
mod document;
mod explorer;
+20 -13
View File
@@ -157,12 +157,9 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
}
if let Some(legacy) = legacy {
rows.push(legacy_line(&context, legacy));
if let Some(interface) = legacy.interface
&& let Some(SharedBody::Interface { items }) =
context.element(interface).and_then(Element::shared)
{
for item in items {
origins.entry(*item).or_default().push(format!(
if let Some(interface) = legacy.interface {
for item in data::interface_items(project, interface) {
origins.entry(item).or_default().push(format!(
"preserved interface {} (suggestion only; not an active typed contract)",
context.label(interface)
));
@@ -711,11 +708,17 @@ mod tests {
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
project = execute_batch(&project, &commands).unwrap();
let interface = add(&mut project, Kind::Interface, "Incident service");
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
Layer::Sa,
SharedBody::Interface {
items: vec![first, second],
},
project.relate(
RelationKind::InterfaceItemAllocation,
"First item",
interface,
first,
);
project.relate(
RelationKind::InterfaceItemAllocation,
"Second item",
interface,
second,
);
let connection = &project.relations[&boundary];
let (a, b) = (connection.source(), connection.target());
@@ -790,8 +793,12 @@ mod tests {
..
} = fixture();
let interface = add(&mut project, Kind::Interface, "Preserved contract");
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
first,
);
let legacy = port(
&mut project,
source,
+3 -6
View File
@@ -168,12 +168,9 @@ impl State {
_ => {}
}
}
if let Some(SharedBody::Interface { items }) = old
.and_then(|old| old.interface)
.and_then(|id| p.elements.get(&id))
.and_then(|e| e.shared())
{
incoming.extend(items);
if let Some(interface) = old.and_then(|old| old.interface) {
let items = data::interface_items(p, interface);
incoming.extend(items.iter().copied());
outgoing.extend(items);
}
for r in p.relations.values() {
+1 -1
View File
@@ -250,7 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
assert_eq!(app.page, Page::Validation);
assert_eq!(app.project(), &original);
assert!(!app.dirty());
assert!(app.source_snapshot.starts_with("arcadia 3;"));
assert!(app.source_snapshot.starts_with("arcadia 4;"));
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+46 -63
View File
@@ -703,69 +703,7 @@ fn inspector(app: &App) -> View<'_, Message> {
}
fn data_catalog(app: &App) -> View<'_, Message> {
let p = app.project();
let mut content = column![heading(
"Data & interfaces",
"Define information, material and energy at the system boundary."
)]
.spacing(15);
for kind in Kind::ALL {
let is_data = matches!(
kind,
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
);
if !is_data {
continue;
}
let elements = p
.elements
.values()
.filter(|e| e.layer() == app.layer && e.kind() == kind)
.collect::<Vec<_>>();
if elements.is_empty() {
continue;
}
content = content.push(
text(format!("{} · {}", kind.label(), elements.len()))
.size(15)
.color(style::kind_color(kind)),
);
for e in elements {
content = content.push(crate::context_menu::Area::new(
button(
row![
column![
text(&e.name).size(14),
label(format!(
"@{}{}",
e.alias,
e.parent
.map(|id| format!(" · in {}", p.named(id)))
.unwrap_or_default()
)),
text(short(&e.description, 125))
.size(12)
.color(style::MUTED)
]
.spacing(5)
.width(Fill),
text("→").size(17)
]
.align_y(iced::Center),
)
.padding(14)
.width(Fill)
.style(if e.id == app.selected {
style::selected
} else {
style::nav
})
.on_press(Message::Select(e.id)),
move |anchor| crate::model_menu::open(e.id, anchor),
));
}
}
scrollable(content.padding(24)).into()
crate::data_view::view(app)
}
fn traces(app: &App) -> View<'_, Message> {
@@ -1099,6 +1037,51 @@ fn checks(app: &App) -> View<'_, Message> {
app.project().named(endpoint.exchange)
)));
}
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
for default in report.semantic_defaults.iter().take(6) {
card = card
.push(action(
&format!(
"{} · {}",
app.project().named(default.object),
default.field
),
Message::Select(default.object),
))
.push(label(&default.description));
}
for allocation in report.generated_interface_allocations.iter().take(6) {
card = card
.push(action(
&format!(
"Created item allocation · @{} · order {}",
allocation.alias, allocation.order
),
Message::Select(allocation.id),
))
.push(label(format!(
"{} → {}",
app.project().named(allocation.source),
app.project().named(allocation.target)
)));
}
for value in report.unresolved_message_values.iter().take(6) {
card = card
.push(action(
&format!(
"Review qualified value · {}",
app.project().named(value.occurrence)
),
Message::EditProperties(value.occurrence),
))
.push(label(&value.reason));
}
if report.semantic_defaults.len() > 6
|| report.generated_interface_allocations.len() > 6
|| report.unresolved_message_values.len() > 6
{
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
}
for boundary in &report.orientation_defaults {
card = card.push(action(
&format!(
@@ -315,3 +315,420 @@ async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_sav
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
server.stop().await;
}
#[tokio::test]
async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart()
{
use oa_core::{
Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection,
PortContract, RelationBody, ScenarioViewpoint, TypeRef,
};
fn values(model: &Project, occurrence: Id) -> &[MessageValue] {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = model.elements[&occurrence].shared()
else {
panic!("Expected message");
};
values
}
fn edited_values(model: &Project, occurrence: Id, next: Vec<MessageValue>) -> Element {
let mut element = model.elements[&occurrence].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = element.shared_mut()
else {
panic!("Expected message");
};
*values = next;
element
}
let mut model = documented_data();
let operation = id(&model, "navigate");
let coordinate = id(&model, "coordinate");
let located = id(&model, "located");
let latitude = id(&model, "latitude");
let origin = id(&model, "origin");
let destination = id(&model, "destination");
let accepted = id(&model, "accepted");
// The second parameter inherits the very same latitude identity.
let Some(SharedBody::DataField { type_ref, .. }) =
model.elements.get_mut(&destination).unwrap().shared_mut()
else {
panic!();
};
*type_ref = TypeRef::Named(located);
let next = model
.add_element(
Kind::DataField,
Layer::Sa,
"Next coordinate",
Some(coordinate),
)
.unwrap();
*model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Named(coordinate),
minimum: 0,
maximum: Some(1),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
unit: String::new(),
default: None,
};
let requester = model
.add_element(Kind::Function, Layer::Sa, "Request navigation", None)
.unwrap();
let provider = model
.add_element(Kind::Function, Layer::Sa, "Provide navigation", None)
.unwrap();
let output = Id::new_v4();
let input = Id::new_v4();
for (port, contract) in [
(
output,
PortContract::FunctionOutput {
outgoing_items: vec![operation],
},
),
(
input,
PortContract::FunctionInput {
incoming_items: vec![operation],
},
),
] {
let (owner, name) = if port == output {
(requester, "Navigation output")
} else {
(provider, "Navigation input")
};
let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap();
model.elements.insert(port, element);
}
let exchange = model.relate(
RelationKind::FunctionalExchange,
"Navigation request",
requester,
provider,
);
model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: requester,
target: provider,
source_port: output,
target_port: input,
items: vec![operation],
};
let scenario = model
.add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None)
.unwrap();
let Some(SharedBody::Scenario { viewpoint, .. }) =
model.elements.get_mut(&scenario).unwrap().shared_mut()
else {
panic!();
};
*viewpoint = ScenarioViewpoint::Functional;
let sender = model
.add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario))
.unwrap();
let receiver = model
.add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario))
.unwrap();
for (line, represented) in [(sender, requester), (receiver, provider)] {
*model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let call = model
.add_element(
Kind::Occurrence,
Layer::Sa,
"Navigation call",
Some(scenario),
)
.unwrap();
let reply = model
.add_element(
Kind::Occurrence,
Layer::Sa,
"Navigation reply",
Some(scenario),
)
.unwrap();
for (occurrence, message_kind, from, to, reply_to) in [
(call, MessageKind::Call, sender, receiver, None),
(reply, MessageKind::Reply, receiver, sender, Some(call)),
] {
*model
.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: Some(from),
receiver: Some(to),
message_kind,
reply_to,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
}
model
.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.extend([call, reply]);
oa_core::validate_candidate(&model).unwrap();
let directory = tempfile::tempdir().unwrap();
let server = TestServer::start(directory.path(), None).await;
let (resource, _) = server.import(&model).await;
let before = server.get(resource).await;
let call_values = vec![
MessageValue {
path: vec![origin],
field: latitude,
expression: " origin.latitude\n+ 0.25 ".into(),
},
MessageValue {
path: vec![destination],
field: latitude,
expression: "destination.latitude".into(),
},
MessageValue {
path: vec![origin, next],
field: latitude,
expression: "previous.latitude".into(),
},
];
let reply_values = vec![MessageValue {
path: vec![],
field: accepted,
expression: "true".into(),
}];
let response = commands(
&server,
&before,
vec![
Command::EditElement(call, edited_values(&model, call, call_values.clone())),
Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())),
],
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let authored = server.get(resource).await;
assert_eq!(authored.revision, before.revision + 1);
assert_eq!(values(&authored.model, call), call_values);
assert_eq!(values(&authored.model, reply), reply_values);
assert_eq!(
authored.model.elements[&latitude],
model.elements[&latitude]
);
assert_pair(&authored);
let filename = directory.path().join(format!("{resource}.json"));
let bytes = std::fs::read(&filename).unwrap();
for (occurrence, value, code) in [
(
call,
MessageValue {
path: vec![],
field: accepted,
expression: "wrong call phase".into(),
},
"message_parameter_phase",
),
(
reply,
MessageValue {
path: vec![origin],
field: latitude,
expression: "wrong reply phase".into(),
},
"message_parameter_phase",
),
(
call,
MessageValue {
path: vec![origin, accepted],
field: latitude,
expression: "unrelated hop".into(),
},
"message_value_field",
),
] {
let invalid = edited_values(&authored.model, occurrence, vec![value]);
let mut invalid_model = authored.model.clone();
invalid_model.elements.insert(occurrence, invalid.clone());
let response = commands(
&server,
&authored,
vec![Command::EditElement(occurrence, invalid)],
)
.await;
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
let problem: Value = response.json().await.unwrap();
assert_eq!(problem["code"], "command_rejected", "{problem}");
assert!(
problem["detail"].as_str().unwrap().starts_with(code),
"{problem}"
);
// Transactions report the first rejection; validation supplies located issues.
let validation: Value = server
.client
.post(server.url(&format!("/projects/{resource}/validation")))
.json(&json!({"format":"json", "model":invalid_model}))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(validation["valid"], false);
assert!(
validation["issues"]
.as_array()
.unwrap()
.iter()
.any(|issue| issue["code"] == code
&& issue["object_id"] == json!(occurrence)
&& issue["field"] == "values.path"),
"{problem}"
);
let unchanged = server.get(resource).await;
assert_eq!(unchanged.etag(), authored.etag());
assert_eq!(unchanged.model, authored.model);
assert_eq!(unchanged.source_project, authored.source_project);
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
}
// Source transport changes one argument without conflating the shared leaf.
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
let edited_source = source.replace(
"\"destination.latitude\"",
"\"destination.corrected_latitude\"",
);
assert_ne!(edited_source, source);
let response = server
.client
.put(server.url(&format!("/projects/{resource}/source")))
.header("if-match", authored.etag())
.header("content-type", "text/plain")
.body(edited_source)
.send()
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let source_edited = server.get(resource).await;
assert_eq!(source_edited.revision, authored.revision + 1);
let mut expected_call = call_values.clone();
expected_call[1].expression = "destination.corrected_latitude".into();
assert_eq!(values(&source_edited.model, call), expected_call);
assert_eq!(values(&source_edited.model, reply), reply_values);
assert_pair(&source_edited);
// Reidentify every kind of enclosing hop, the shared terminal, and its base type.
let mapping = [origin, destination, next, latitude, coordinate]
.into_iter()
.map(|old| (old, Id::new_v4()))
.collect::<BTreeMap<_, _>>();
let response = commands(
&server,
&source_edited,
mapping
.iter()
.map(|(old, new)| Command::Reidentify(*old, *new))
.collect(),
)
.await;
assert_eq!(
response.status(),
StatusCode::OK,
"{}",
response.text().await.unwrap()
);
let changed = server.get(resource).await;
assert_eq!(changed.revision, source_edited.revision + 1);
for value in &mut expected_call {
value.path = value.path.iter().map(|hop| mapping[hop]).collect();
value.field = mapping[&value.field];
}
assert_eq!(values(&changed.model, call), expected_call);
assert_eq!(values(&changed.model, reply), reply_values);
assert_eq!(
changed.model.elements[&mapping[&latitude]].parent,
Some(mapping[&coordinate])
);
assert!(
matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: Some(base) }, ..
}) if *base == mapping[&coordinate])
);
assert!(
oa_core::data::effective_fields(&changed.model, located)
.iter()
.any(|field| field.id == mapping[&latitude])
);
for old in mapping.keys() {
assert!(!changed.model.contains(*old));
}
assert!(
matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, ..
}) if *correlation == call)
);
assert_pair(&changed);
let source = server
.client
.get(server.url(&format!("/projects/{resource}/source")))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.text()
.await
.unwrap();
assert!(source.starts_with("arcadia 4;"));
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
let saved = std::fs::read(&filename).unwrap();
server.stop().await;
let server = TestServer::start(directory.path(), None).await;
let reopened = server.get(resource).await;
assert_eq!(reopened.etag(), changed.etag());
assert_eq!(reopened.model, changed.model);
assert_eq!(reopened.source_project, changed.source_project);
assert_eq!(values(&reopened.model, call), expected_call);
assert_eq!(values(&reopened.model, reply), reply_values);
assert_pair(&reopened);
assert_eq!(std::fs::read(&filename).unwrap(), saved);
server.stop().await;
}
+4 -3
View File
@@ -984,9 +984,10 @@ declares <span class="docutils literal">outgoing_items</span>; <span class="docu
The lists contain distinct same-layer ExchangeItem references. A functional
exchange MUST reference output/input ports owned by its respective functions,
and each carried item MUST fit both declarations. Empty declarations accept
no items. Item compatibility is identity equality; future specialization is
not inferred from names. Port declarations remain independent of incident
exchanges after creation.</p>
no items. A carried item satisfies an identical declaration or a declaration
of one of its explicit base items. A base item does not satisfy a more
specialized declaration; names imply no compatibility. Port declarations
remain independent of incident exchanges after creation.</p>
<p>A <span class="docutils literal">Component</span> contract belongs to an SA System or SystemActor and stores
<span class="docutils literal">kind</span> (Standard or Flow) and <span class="docutils literal">orientation</span> (Unset, In, Out, InOut).
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
+4 -3
View File
@@ -311,9 +311,10 @@ declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``.
The lists contain distinct same-layer ExchangeItem references. A functional
exchange MUST reference output/input ports owned by its respective functions,
and each carried item MUST fit both declarations. Empty declarations accept
no items. Item compatibility is identity equality; future specialization is
not inferred from names. Port declarations remain independent of incident
exchanges after creation.
no items. A carried item satisfies an identical declaration or a declaration
of one of its explicit base items. A base item does not satisfy a more
specialized declaration; names imply no compatibility. Port declarations
remain independent of incident exchanges after creation.
A ``Component`` contract belongs to an SA System or SystemActor and stores
``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut).
+3 -3
View File
@@ -8,12 +8,12 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
|---|---|
| Operational Analysis | Entities, actors, roles, activities, missions, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
| System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links |
| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
| Data and interfaces | Information/material/energy nature and Flow/Event/Operation/SharedData mechanisms, bounded primitive/enum/record types, typed defaults, field multiplicity/units/direction/storage, specialization, identified interface allocations and reference associations, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
| Behavior | Process/chain work and exchange contributions, control topology and sequence reuse; scenario lifelines, correlated calls/replies, timers, action/state occurrences, ALT/PAR/LOOP/OPTIONAL fragments, scenario reuse and pre/postconditions |
| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints |
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
| Text and storage | `arcadia 3` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
@@ -45,7 +45,7 @@ These are concrete design responses, not claims that every Capella user experien
## Required first-release work
Direct state/scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
## Remaining boundaries
+2 -2
View File
@@ -1,6 +1,6 @@
# Arcadia Studio server
`arcadia-server` is a headless Rust REST service for the same schema-3 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
`arcadia-server` is a headless Rust REST service for the same schema-4 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
The complete HTTP contract is [api.rst](api.rst); machine-readable schemas are in [openapi.json](openapi.json), also served at `/api/v1/openapi.json`. The original [service research](research-v2-service.md) discusses broader alternatives; retained history, SQLite, idempotency receipts and stored transition plans from that research are **not** the implemented storage contract.
@@ -44,7 +44,7 @@ The data directory contains `.arcadia.lock` and one `<resource-id>.json` file pe
Startup validates every committed envelope, its filename/UUID, revision, size and complete model. Any corrupt or unsupported envelope stops startup; the service does not silently skip bad data or invent an empty replacement. Sibling `.arcadia-write-*` files from an interrupted pre-commit write are not committed models and are ignored on startup.
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled envelopes must contain stable source IDs and matching model/source; inconsistent pairs are rejected at startup. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled schema3 envelopes explicitly migrate both representations in memory; the independently migrated graphs must agree. Current-format source must contain stable IDs. Inconsistent pairs are rejected at startup. Migration reports remain visible until a current-format edit/save; opening alone never rewrites the envelope. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation, source writeback and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once. Both model and source bundle must be unchanged for `changed: false`; comment-only edits advance the same revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file.
+6
View File
@@ -63,3 +63,9 @@ State camera/tool lifetime follows effective machine and diagram identity, even
when selection changes through Catalog or Layer outside Behavior. Reset only on
actual context change; preserve same-view return. Independent review reproduced
the leak and native Windows/Linux tests verified the correction in316dc55.
- Carried-value property browsing follows one exact record scope at a time, with
bounded pages and a separate read-only browser location. Flattened representative
paths were proven ambiguous in native recursive/reused data. Browsing does not
change the selected field or expression; choosing a field remains an explicit
draft edit through the ordinary validated command. No persistent browser schema.
+1 -1
View File
@@ -4,7 +4,7 @@
Deliver the first release of Arcadia Studio for system engineers: complete Operational Analysis and System Analysis/System Architecture workflows, equivalent graphical and textual models, clear artifact traceability and a deployable/programmatically accessible Rust service. Logical/Physical Architecture and later Arcadia layers are future releases, as the new objective explicitly states.
The authoritative objective is attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
The authoritative objective is attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
## Existing architecture and retained decisions
+97 -36
View File
@@ -2,17 +2,53 @@
## Objective and phase
Direct process/chain editing is frozen in `codex/process-direct` at production
8bc564b, based on 1bf8ebc, in `D:/CodexBuilds/Capella2-scenario-direct`. The prior
scenario branch is retained. Independent review found and this revision fixes
the empty-owner → first exchange continuation dead end: **Append exchange…**
captures the last ordered contribution and creates the next occurrence plus its
explicit sequence link in one validated model/source Batch. No mode flag or
arbitrary topology is added. Both OA/SA regressions cover exact source history,
reopen, preservation of longer ordered paths and incompatible-source rejection.
Full Windows desktop tests pass 305/305; strict all-target desktop Clippy and
optimized build pass. Original independent core topology/diagnostics/nested-source
verification remains 19/19 at 7a1e12b. The current frozen binary, manifest and logs
are at `D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`.
`release-process-editing.md` records behavior, verification and exact artifact hash.
No native operation was performed by this worker. Root review/native follow-up
must include empty → first exchange → Append exchange on Windows/Linux. The
accepted model/source/API contract is unchanged; cross-reference inner-chain
linking and disconnected exchange-only topology remain explicit model boundaries.
Direct scenario work is now in isolated `codex/scenario-direct`, checkout
`D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target
`D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in
release-scenario-editing.md. Existing scene/order semantics and typed drafts are
retained; direct drawing, explicit insertion, same-parent reorder and correlated
activation rendering are implemented in d1d8f0b, with independent-review fixes
in 7a66607 for stale scroll slots, actual Iced scroll coordinates, operand-specific
activation segments and repeated picker cost, plus e7e37d4 for passive scroll
observations during async save. DATA b35dede is integrated in dfd2e41 without
conflicts. Combined Windows desktop tests pass 286/286, strict all-target desktop
Clippy and optimized build pass; a copied artifact/manifest is frozen under
`D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`. Exact artifact
hash, before/after failures and timings are in release-scenario-editing.md.
Focused independent recheck reports no remaining medium/high finding. Native
Windows/Linux trials remain pending and root-owned; this branch claims no native
acceptance and does not close process/chain work.
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
textual and REST semantics, artifact traceability, directly editable behavior,
offline guidance, and Windows/Linux release evidence. Later Arcadia layers remain
future releases. The full authoritative objective was reread on 2026-09-06:
attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md.
attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
Direct state-machine editing is accepted through production316dc55. The main
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts are now
being implemented in a separate checkout; they are not accepted or released.
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts
through b35dede are now integrated into the isolated direct-scenario checkout.
Root retains final main integration and acceptance; the combined direct scenario
has not yet completed native trials.
Meaningful implementation and runtime progress continues; no external blocker exists.
## Accepted foundations
@@ -76,44 +112,57 @@ Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/
with full data payload and dictionary slices together; existing typed ports remain.
See release-data-implementation.md and release-data-contract.md in that checkout.
Core is committed0b58107; source/migration1230ab8. Root API/docs are integrated
on the same working tree; desktop remains in flight and R6 is not accepted.
Core15 R6 +28workflow tests passed (final allocation-arrow suppression arrived
afterward and is queued for rerun). Earlier13ports/14scenarios and115 source/lib
tests passed; strict core Clippy preceded the last geometry/performance fixes.
R6 core/source/desktop are frozen at ab661f2. Full Windows607 tests and Linux610
tests pass (one explicit benchmark ignored on each platform); strict workspace
all-target Clippy passes on both. Optimized desktop/server Linux build also passed.
Windows desktop261 tests include the bounded scenario value editor. Exact logs,
binaries and runtime evidence: release-data-verification.md.
Root41 server tests pass, including3 new actual HTTP workflows for the published
data example, protocol/artifact identity, atomic rejected defaults, query/export,
reidentify/delete/restart, source3 bundle migration reports and old coupled3
envelope upgrade without write/revision. Tests exposed dropped bundle reports and
rejected legitimate old source/model pairs; both root fixes passed retest.
Strict reST HTML generation and81 OpenAPI schema definitions passed. Actual frozen
server32033FFE imported the published example; its complete Project/StoredProject
responses passed served/current JSON Schema validation and obsolete shapes rejected.
Evidence: api-r6-contract and r6-server-tests-after.log under the release directory.
All4 R6 actual HTTP workflows pass, covering data protocols/artifact identity,
rejected defaults, qualified Call/Reply paths, validation locations, source edits,
reidentification, source3 migration and restart persistence. The published example's
actual server responses passed served/local OpenAPI validation; obsolete shapes
reject. Strict reST rendering and81 OpenAPI schemas passed.
Independent core review found association transition closure pulling unrelated
types and missing empty-enum warning; both corrected and focused tests passed.
Repeated per-ancestor full scans were simplified to one scan per helper call.
Explicit debug inheritance timings250/500/1000:147/664/2739ms. Optimized timing is
still required; no optimized performance claim yet.
Explicit optimized inheritance timings250/500/1000: Windows21.070/89.088/391.590ms,
Linux22.381/111.662/408.129ms. These single-run empty-chain fixtures measure core
validation, not general UI latency. A1000-deep chain remains a pathological cost.
Current ownership: drawing_correctness owns desktop except data_view.rs;
state_architecture_review now owns only data_view.rs bounded focused-dictionary
correction. Its independent review found unbounded inherited rows/picklists,
unreachable recursive paths and loss of scenario values on same-phase style or
same-exchange reselection. Drawing worker fixes recursive drill-down/draft retention.
data_core now verifies Linux in a separate data-linux-review checkout/target.
Root owns API/OpenAPI/reST, integration and native input. No concurrent file writers.
Independent desktop review fixes now pass tests: a focused dictionary with24-row
pages replaces unbounded inherited rows; recursive paths support explicit drill-down
and imported finite paths; same-phase styles/same-exchange reselection preserve
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
only on actual layer/document changes; its regression failed before and passes now.
The bounded scenario editor replaces whole-recursion enumeration and per-field
inputs with24-row browsing and a single focused value draft. The reply call picker
no longer filters alternative calls using values belonging to the old exchange.
Automated and independent checks pass; native scenario checks are next.
Root exclusively operates native R6. The Windows13-object data model has saved
native bounds/default repair, a typed physical flow/field, allocation protocol
edit, allocation removal/Undo, and inherited-owner navigation. Association endpoint
labels/selectability were exercised. The1100x700 data layout has been inspected.
A separate53-object recursive-operation fixture was prepared with core APIs and
round-tripped for upcoming native scenario trials; fixture construction is not
claimed as native authoring. See the verification note for remaining gates.
Parallel isolated work: drawing_correctness implements direct scenario gestures in
Capella2-scenario-direct. data_core owns typed semantic review core and
state_architecture_review owns source5/migration in Capella2-semantic-review.
The typed facet C design won the bounded experiment/criticism round; its production
plan and decision are recorded in that checkout. No overlapping writers or targets.
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
the same evidence in c5276b1. Finish the3 concrete desktop review fixes and
optimized inheritance measurement, then native data-authoring acceptance.
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
scenario-value authoring, then full integrated Windows/Linux verification.
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
baselines/reconciliation only cover prose. Prefer historical typed-body snapshots
and finite proposals; avoid a duplicate recursive semantic schema without evidence.
baselines/reconciliation only cover prose. Use the experimentally selected typed facet review design C; the semantic
integration checkout records the full scope and source5 migration plan.
4. Complete direct scenario/process gestures, persistent coverage/filters,
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
@@ -121,12 +170,13 @@ Root owns API/OpenAPI/reST, integration and native input. No concurrent file wri
## Environment and native ownership
Root exclusively owns native input. Latest saved Windows trial is window1706782,
ui-state-navigation-windows,49objects/28links/1 expected missing-owner warning.
Linux navigation trial3148598 closed normally, exec46107 exit0; baseline Linux
trial721472 also closed normally, exec72680 exit0. Independent Windows3672536 is
saved at1100x700. Older root drawing windows are saved. Re-inventory before input.
Protected user7210312/6228978 were absent; do not reset/save/close if they return.
The user explicitly reauthorized native testing after Escape. Root's f5467e5 R6
window3476204 and exec93768 closed normally after verified source migration and
55-object recursive scenario reopen; no runtime output. The property-browser
replacement build b35dede has passed the root's Windows native retest. The
combined direct-scenario binary is frozen for its separate pending native trial.
Original Capella is present. Older protected user7210312/6228978 were absent;
do not reset/save/close them if they return. Re-inventory before switching apps.
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
@@ -137,3 +187,14 @@ Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
Latest R6 checkpoint: b35dede Windows264 desktop tests, strict Clippy, optimized
build and root-owned native retest pass for the one-record property browser. Paging and
location changes retain accepted model and typed expressions. f5467e5 Windows
and Linux263 desktop tests, strict Clippy and optimized release passed. Native
Windows source3 migration preserved comments/nesting through explicit Save All
and clean source4 reopen. Native recursive55-object scenario values and paired
Call/Reply history were verified; flat property paths prompted the latest fix.
The independent bounded-browser review reports no remaining medium/high finding.
Linux native R6 workflows remain a root-owned acceptance gate. See
release-data-verification.md. Main remains a8f451e; R6 is not yet accepted.
+13 -3
View File
@@ -1,6 +1,6 @@
# Task Ledger — first release
Authoritative objective: attachment07aee762-7d27-4cbe-81c3-2e82c7170711/
Authoritative objective: attachment10134df9-bea2-4c02-a820-67b53d6b86f7/
goal-objective.md, read fully2026-09-06. STATE.md records execution; PLAN.md retains
scope; DECISIONS.md and linked contracts preserve rationale.
@@ -39,6 +39,13 @@ requires native workflows, semantic evaluators and fresh independent review.
whole-model equality or flat reformatting. Independent counterexamples were fixed.
- R3 existing-target reconciliation remains name/description only. R6 data shape
expansion is active implementation, not accepted functionality.
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
strict Clippy/release passed on both platforms. Native Windows data editing,
typed defaults, allocations, recursive qualified Call/Reply values, history and
saved reopening passed. Source3 migration now preserves comments and nesting.
Native found flat property browsing still ambiguous; one-record browsing with
bounded pages now passes264 desktop tests/Clippy, pending rebuilt native checks.
Linux native R6 acceptance remains outstanding.
## Assumptions and open questions
@@ -48,8 +55,11 @@ requires native workflows, semantic evaluators and fresh independent review.
gestures still need their own occurrence/order semantics and task verification.
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
- R3 typed historical bodies plus finite proposals appear simpler than a separate
recursive semantic-value schema. This candidate awaits R6 freeze and review.
- R3 selected design C uses finite self-tagged typed facets, separate membership
and order acknowledgements, and transient constructor groups. Source5 historical
references are implemented in the isolated semantic checkout. Independent review
found narrow-acknowledgement/canonical-agreement/rollup defects; the worker has
added regressions and fixes. Full catalog, GUI/API/spec and acceptance remain.
## Decisions and current strategy
+119
View File
@@ -0,0 +1,119 @@
# R6 data contracts — native verification in progress
R6 is not accepted. Main remains at a8f451e. The full first-release goal remains
active and incomplete. User explicitly reauthorized native testing after Escape.
## Frozen production and automated evidence
Data integration ab661f2: Windows607 and Linux610 workspace tests pass, one explicit
benchmark ignored each; strict workspace all-target Clippy passes on both.
Windows261 desktop tests include the focused bounded scenario editor; Linux release
desktop/server build passed. Logs under D:/CodexBuilds/Capella2-release1-01a06e2e:
r6-workspace-windows-ab661f2.log and r6-workspace-windows-clippy-ab661f2.log.
Linux logs in Capella2-data-linux-review/target/linux-verification:
workspace-ab661f2.log, workspace-clippy-ab661f2.log, release-ab661f2.log.
Windows frozen executable ui-r6-windows/arcadia-studio.exe SHA256
C3ABD9E550E1AD6824DFE273EB27A3ABB35C5C0DA97C819DCB0AB2586AF5450C.
Current native window1835476, process session37598. No output observed; runtime.log
has not been created by Tee-Object (do not claim an empty log file).
All4 actual R6 HTTP tests pass with strict server Clippy: published data example,
protocol/artifact identity, rejected defaults, qualified reused/inherited/recursive
Call/Reply paths, phase/path422 with unchanged ETag/model/source/disk, source edits,
hop/terminal/base reidentification, correlation and restart persistence. Source3
coupled/source bundle migrations report correctly and opening alone does not write.
Logs: r6-http-paths-final.log, r6-http-all-data.log, r6-http-paths-clippy.log.
Initial fixture errors (untyped Port and transaction-error envelope assumption)
were corrected before passing. Published example responses from server32033FFE
passed served/local OpenAPI validation; obsolete shapes rejected. Strict reST HTML
and81 OpenAPI schemas passed. One stale port compatibility paragraph was corrected.
Optimized empty inheritance chains250/500/1000: Windows21.070/89.088/391.590ms;
Linux22.381/111.662/408.129ms. Single-run core validation timings, not UI latency.
## Personally observed Windows native workflows
Loaded the isolated Engineering data source fixture. Its architecture view shows
nested fields, association role/multiplicity labels, self-reference navigation and
selectable endpoint labels. No selection-induced dirty state observed.
Voltage maximum48→12 with default24 rejects when navigating; entered12 survives,
accepted summary remains0..48/default24, and a located default error is shown.
Repair default→12 applies together; Undo restores both old values, Redo restores
both new values. Created Cooling water with Material/Flow and contained Mass flow
rate Real1..1, unit kg/s, explicit default0.0. Save All wrote an included sa/types.arcadia
file with matching typed values, leaving13objects/4links/1 expected missing-System
warning. Navigation allocation changed Synchronous→Asynchronous; Monitoring's
separate Asynchronous/Read allocation is preserved. Removing Monitoring allocation
keeps Navigate; Undo restores the allocation and original checks. Followed inherited
Latitude from Located asset to Coordinate. Saved all edits. Data layout inspected
at1440x900 and1100x700; no overlapping controls observed.
Separate ui-r6-scenario-windows/model/project.arcadia was constructed with the core
API from the saved native data, adding functional ports/exchange, correlated
Call/Reply, reused/inherited/recursive records and30 extra fields.53objects/5links
validate and source round-trip exactly. fixture-generator.rs retained as evidence;
temporary project example removed. Initial generator compile/load errors were
fixture-only and fixed using the bundle-aware loader; final build has no warnings.
This preparation is not native scenario authoring evidence.
## Remaining acceptance gates
Native scenario focused/path/phase/draft behavior and paging; actual saved reopen;
source3 migration diagnostics; Linux native data/scenario workflows. Closure of
material findings before R6 acceptance. Independent semantic/correctness/spec
reviews already drove fixes; final native observations may require further review.
The broader release still needs semantic reconciliation, direct scenario/process
editing, lifecycle/hosted/help work and fresh cross-disciplinary end-state review.
## Native findings and follow-up fixes (2026-09-06)
At1100x700, the53-object recursive fixture exposed32/33 nested fields in two pages.
Native Call+Reply creation stored distinct Origin/Latitude and Destination/Latitude
expressions despite their shared terminal UUID. One Undo removed the pair; Redo
restored it. Save All wrote55objects/5links; source confirms both paths and reply
correlation. Three expected checks concern the absent SA System and two unallocated
functions. The saved evidence is ui-r6-scenario-windows/model/project.arcadia.
A third-hop popup exposed a material UI defect: repeated long path prefixes hid
all terminal field names. Fix74989b6 shows the terminal name first, sorts choices,
and retains the full address as wrapping detail text.262 desktop tests, strict
Clippy and release build passed; native retest is pending. Intermediate fixed hash
E592165A6FFFAEC64B67FF0A33FB4E78571527EE3C8F2CC457666DA6C277D5CB.
Native source3 import showed8semantic defaults,2generated allocations,1unresolved
value address and left the original hash305F19D1B79F99F8D4CF627BA2E9CA6836BB7959CC0070227815937A699EAFEA unchanged.
Its old desktop Save All path requested a new file and preserved the original, but
canonical regeneration discarded comments/nesting in the upgraded copy. This is a
material desktop/source parity defect, not a core bundle migration defect. The fix
keeps the historical single-document adapter only for source1/2; source3+ follows
bundle migration and propagates its report into Model checks. A regression passes
for actual disk single-file and included-file open, no write until explicit save,
comments, lexical ownership, report, exact saved source and clean reopened state.
Full desktop/Clippy/build and native retest are in progress; do not accept R6 yet.
The old Windows native window1835476 closed normally; exec37598 exited0 with no
runtime output beyond a newline. Re-inventory before opening the rebuilt binary.
## Migration retest and bounded property browsing
f5467e5 passed263 desktop tests, strict all-target desktop Clippy and optimized
release on Windows/Linux. Frozen Windows SHA256
33D1B2B6709BBC734E265568C7F93CB86A506497DEF3F4531669E5ABF8B2F039.
Native fresh legacy.arcadia import preserved original bytes until Save All,
displayed8defaults/2allocations/1unresolved address, and retained comments and
nested fields in its text editor. Save All kept the same file and source4 wording;
reopen was clean15objects/3links/4checks without a migration report. Saved SHA256
A8C0A689FEA6CC352007ACB127DE4A45E05290830DE815385CFFBAB777255543.
Evidence: ui-r6-final-windows/migration/legacy.arcadia.
The55-object recursive scenario reopened with both qualified expressions intact.
Leaf-first labels fixed the hidden terminal names, but existing-value properties
still flattened every representative path, causing repeated indistinguishable
leaf names. The follow-up browses one record at a time, with24-choice pages and
wrapping current address/location text. Up/root/page actions change only browser
state; draft field and expression are preserved.264 desktop tests and strict
Clippy passed, including wide-record page coverage and draft preservation across
recursive browsing. Optimized build and native retest remain pending. Frozen
f5467e5 window3476204 closed normally; exec93768 had no runtime output.
+186
View File
@@ -0,0 +1,186 @@
# Direct process / functional-chain editing
Status: implementation and automated Windows verification passed on 2026-09-06 in `codex/process-direct`, based on
1bf8ebc in `D:/CodexBuilds/Capella2-scenario-direct`; dedicated target remains
`D:/CodexBuilds/Capella2-scenario-target`. The prior scenario branch is retained.
No native acceptance is claimed; root owns Windows/Linux native operation.
## Decisions and observable outcome
An engineer can involve existing work/exchanges more than once, draw guarded
sequence links and controls, insert on a selected path, reconnect its endpoint,
inspect/reuse a subprocess, and undo/save/reopen with contextual identities intact.
Extend the existing derived topology graph and ordinary typed drafts. No core
schema, persisted geometry, source grammar or REST route is added.
Palette tools capture document/owner/revision/epoch and a specific contribution,
link or presentation slot. Canvas click-click and drag connect local occurrence
IDs. A link insertion preserves the captured link UUID, alias, description and
guard on the upstream segment, creates the downstream segment and new occurrence,
and updates `steps` in one validated Batch. Membership/presentation order remains
separate from SequenceLink topology. Incomplete control guards/cardinalities are
warnings, as in the core. Reconnection retains the link's authored properties.
Select always means the local occurrence/relation. Shared source navigation uses
explicit Open; reusable previews expand without copying or reparenting source
contributions. Inner preview endpoints are not caller-owned endpoints. Capella's
richer cross-reference hierarchy linking is an unresolved semantic extension,
not implied by this milestone. Existing explicit legacy-path conversion remains.
Keep canvas and bounded draft independently scrollable, use searchable paged
contextual choices, and default to readable zoom with explicit Fit and scrolling.
Transient drawing cancels on context, tool, bounds, scroll, resize or focus changes;
form inputs survive failed validation/source updates. Reuse passive scroll
observations including async-save delivery. Commit through Document::execute,
acknowledging and selecting the created occurrence only after model/source success.
## Ownership and verification
Own `behavior/topology.rs` and local tests, with narrow `behavior.rs` and `app.rs`
routing/lifecycle changes. Do not change DATA/Linux/semantic checkouts or perform
native input. Verify production pointer events, planners, App and model/source:
1. OA/SA repeated work/exchange involvement preserves shared records.
2. Alternative/parallel/loop creation, guards and incomplete warnings.
3. Specific guarded-link insertion preserves metadata; incompatible adjacency rejects.
4. Reconnect/delete/Undo/Redo retain unrelated paths and exact source.
5. Repeated subprocess reuse, explicit navigation, expansion and recursive rejection.
6. Legacy conversion and presentation reorder independent of explicit topology.
7. Dirty/source-failure/stale/scroll-save/resize/Escape/focus lifetimes retain drafts.
8. Nested source save/reopen and ordinary serialized command/model parity.
Run focused invariants, all desktop tests, strict all-target desktop Clippy and
optimized desktop build with --locked --offline and the dedicated target. Record
actual evidence and remaining native tasks before freezing. Root independently
reviews and native-tests the freeze; full-release completeness remains separate.
## Original implementation freeze and actual evidence — 2026-09-06
Production is `7a1e12bc7c18c5693de688f054293bacd095f4d8` (`7a1e12b`).
The prior `codex/scenario-direct` branch remains at its original head. Production
changes are confined to desktop topology and its local direct/view/test modules,
App process routing, the existing passive-scroll observer and one updated passive
lifecycle regression. `scenario.rs` and scenario message-label construction are
unchanged, allowing the root's separate DATA native fixes to merge independently.
The palette now draws work/exchange/reuse/control contributions and sequence links
with click-click or drag. Selected link endpoint handles reconnect by identity;
selected contribution handles reorder peers within one derived path column.
Source navigation is explicit. Expanded reference previews expose all directly
owned source contributions without copying them or making inner endpoints local.
Every picker has search and 24-item pages. Active tools have distinct styling.
Create/Cancel are outside the form's internal scroller. The canvas stays visible
while forms are pending. New occurrence selection is restored after the final
SetSteps command, and successful edits reveal it in the graph.
The 100% default and manual 50% zoom floor keep authoring legible. Explicit Fit
may produce a small overview; choosing a drawing tool leaves that overview at
100%. No positions are persisted. A source-only exchange sequence retains its
legacy linear meaning: two or more entries have explicit Convert. The original
one-exchange fallback offered only Work/Control/Reuse; independent review found
that this blocked exchange-only continuation. The correction is recorded below.
Disconnected exchange-only topology cannot be represented by the accepted
contract; the editor does not introduce an invisible mode flag.
The new App resize regression initially failed because existing window handling
only invalidated scenario contexts; it now invalidates process contexts too.
Passive viewport and both scroll reports are accepted before busy/modal gates,
validated by document/owner/revision, and cannot apply properties or discard a
behavior draft. The shared observer distinguishes its scenario/process delivery.
A separate captured-link probe failed because two existing parallel guards had
the same hit target. Distinct derived routes now make each relation selectable;
source/target/UUID/guard remain unchanged. The failing probe is saved alongside
the passing final suite as `process-parallel-selection-before.log`.
Actual commands used `--locked --offline` and the dedicated scenario target:
- Full desktop tests: **303 passed**, zero failed/ignored. Seventeen new process
tests exercise production pointer sequences, exact guarded-link insertion,
duplicate-link selection, same-owner reorder, source failure/drafts, busy save
scrolling, dirty source/properties, real resize, reusable-source Open/Back,
bounded widget trees and nested multi-file source/serialized-command parity.
- Strict all-target desktop Clippy: **passed** (`-D warnings`).
- Independent existing core suites `release_topology`, `process_diagnostics` and
`source_nested_updates`: **19 passed**. These cover complete alternative,
parallel and loop topology; two-direction transition closure; deletion and
reidentification; legacy sequences and exact nested source ownership/history.
- Optimized desktop build: **passed**. Owned sources were formatted with Rustfmt
edition 2024; `git diff --check` passed before the production commit.
The source/JSON commands and core invariants are unchanged. These tests do not
claim a new actual HTTP transport trial or native acceptance.
Frozen Windows directory:
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-7a1e12b`.
It contains the binary, manifest and five verification/probe logs. Binary size:
20409856 bytes. SHA-256:
`701ECE6CD329A1CBE50D4DAEBFCE33AD7A93E6906295E74C3290653706F8455A`.
The frozen copy was hash-compared with the release target binary.
## Independent-review correction — exchange-only continuation
Production follow-up is `8bc564b4b2c3b6d12eb61fc09aec7f129f6e45b7` (`8bc564b`).
Independent review found a real dead end: after the first Exchange was created in
an empty process/chain, the accepted model classified it as an ordered exchange
path. There was no path to convert and the fallback omitted another Exchange.
Ordered paths now offer **Append exchange…**, including a single contribution.
The form names the captured final contribution and explains that Create adds an
explicit sequence link. Its contextual picker accepts exchanges whose source
matches the preceding exchange's target. Create emits one existing validated
Batch: new exchange occurrence, preceding-to-new SequenceLink and SetSteps;
existing implied links in longer ordered paths are materialized in that same
transaction. The second contribution therefore restores the direct topology
palette without inventing disconnected topology, global exchanges or a mode flag.
Source reuse, same-owner endpoints and the cross-reference semantic boundary remain.
Two new regressions both exercise OA and SA. The App regression starts with an
empty owner, creates its first exchange, invokes the actual Append event and
creates its second exchange. It verifies exactly one link, two distinct occurrence
IDs, unchanged shared records, one history entry per Create, exact model/source
Undo/Redo and source reopen. The independent proposal regression preserves a
multi-entry ordered path, rejects an incompatible exchange without losing its
form, and validates the serialized ordinary Batch through core execution.
Focused regressions: **2 passed**. Full desktop tests: **305 passed**, zero
failed/ignored. Strict all-target desktop Clippy: **passed** (`-D warnings`).
Commands used `--locked --offline` and the same dedicated target. The final full
suite took 10.75 seconds; Clippy took 4.96 seconds. Production changes are confined
to `topology/direct.rs`, `topology/view.rs`, local tests and `app/process_canvas.rs`.
No native input or new actual HTTP transport trial was performed.
Updated optimized build: **passed** in 38.80 seconds. Current frozen Windows
artifact, manifest and three verification logs are in
`D:/CodexBuilds/Capella2-scenario-target/freeze-process-8bc564b`.
Binary size: 20430336 bytes; SHA-256:
`8BD5C84DF0F4E333C1955E6E9299F431081912E1A504ED13355E0BFA70F8B8BD`.
The frozen copy was hash-compared with the release target binary. The prior
`freeze-process-7a1e12b` remains available as historical evidence.
## Root-owned native acceptance still required
At 1440x900 and 1100x700 on Windows and Linux, operate the actual application:
1. Start an OA process and SA chain, involve matching existing work/exchanges,
repeat a shared source with a distinct contextual name, and draw links with
both click-click and drag. Inspect source identity and contextual properties.
2. Draw Choice/Merge alternatives and Fork/Join parallel paths; enter `ready` and
`else`, create a loop repeat/exit, and inspect incomplete warnings. Insert an
exchange/control on an existing guarded path, reconnect an endpoint and reject
an incompatible target; confirm the original path metadata and other branch.
3. Reuse a subprocess twice, select its body without navigation, expand one local
reference and inspect the count, Open the shared source, then Back to its caller.
Reorder peer contributions and verify links are unchanged; cross-column movement
is rejected with the existing topology explanation.
4. Test Fit/100%, inner and outer wheel/scrollbar changes, resize, Escape and
unfocus during an armed or held gesture. Verify no stale endpoint/slot commits.
Confirm Create/Cancel remains accessible while a long form is open, search/page
changes retain its values, navigation is guarded and save-time scrolling recovers.
5. Start an empty owner, add its first exchange, use Append exchange for its second,
and verify the visible link/direct palette. Also append/convert a legacy multi-exchange path.
Undo/Redo the conversion and direct edits, save/reopen a nested source project,
then inspect the same occurrence/link IDs, guards and shared source records.
No native/UI input was performed in this task. Cross-reference inner-chain linking
remains an explicit unresolved semantic extension; this milestone does not claim
full Capella functional-chain equivalence or complete the first release.
+236
View File
@@ -0,0 +1,236 @@
# Direct scenario editing — bounded implementation plan
Status: implementation and automated Windows checks passed in the isolated
`codex/scenario-direct` checkout, based on ab661f2. Initial production is d1d8f0b;
independent-review fixes are in 7a66607 and e7e37d4. The focused independent
recheck reports no remaining medium/high finding. Root-owned Windows/Linux native
trials remain pending. DATA b35dede is integrated in dfd2e41 with 286 desktop
tests, strict Clippy and a fresh frozen Windows release artifact. This does not
complete the release or process/chain work.
## Decisions
Extend the existing `behavior::Scene`; retain its derived lifeline columns and
ordered occurrence rows. No model/schema/layout records are added. Lifeline and
occurrence UUIDs remain distinct from represented work and global exchanges.
Select mode selects headers, stems, messages and occurrence boxes. A selected
occurrence has a reorder handle; dragging it chooses a visible slot in its same
parent sequence. It never reparents or changes lifeline columns. A Message tool
uses the existing MessageStyle, with Call + correlated reply initially selected.
Click-click or drag between represented lifelines captures a highlighted explicit
insertion slot `(parent, before occurrence or append)` and opens the existing
typed draft with those endpoints. Exchanges are filtered by layer, viewpoint and
the core's represented-endpoint compatibility. Earlier calls retain complete
exact-slot validation, cached by document revision, owner, slot and endpoints.
Choices use search plus 24-item pages; every final creation still validates the
complete ordinary command at the captured insertion slot.
No compatible exchange produces an actionable retained draft, not a new global
exchange or a silently changed endpoint. Timer remains available as a local tool.
The canvas remains rendered while a bounded, independently scrollable form is
open. Values keep the existing qualified-path editor and lifecycle. Final creation
uses ordinary CreateElement plus SetSteps in one Batch; paired call/reply are
inserted together and undo together. Reordering uses SetSteps within the captured
parent. Existing core validation decides reply order, branch dominance, lifetime,
phase and endpoint compatibility. Failed preparation/source writes retain drafts.
Transient events capture document/scenario/revision/epoch, and the App validates
the current Behavior owner before handling them. Selection does not invalidate a
gesture within the same scenario. Escape, unfocus, context/tool change and pending
drafts cancel gestures. Scroll, bounds changes and actual window resize also
cancel the captured source/slot. Ordered raw pointer events are translated by the
actual combined inner/page Scrollable offsets. No geometry is persisted.
Execution bars derive their ends from actual correlated reply positions; separate
conditional reply branches render their corresponding spans. Calls without a
visible reply use a short explicitly open activation, not a fabricated reply.
## Acceptance checks
Production-event tests cover hit priority, click-click/drag, scroll-origin changes,
Escape/unfocus/stale contexts and exactly one result per gesture. Planner/App tests
cover middle/operand insertion, paired identities, complete SetSteps ordering,
same-parent reorder, rejected reply-before-call, unchanged global exchanges, exact
source/model Undo, draft retention and bounded exchange/call controls. Run focused
and full desktop tests, strict all-target desktop Clippy and optimized build with
the dedicated `D:/CodexBuilds/Capella2-scenario-target` target.
Root native tasks (not yet performed):
1. Draw an existing exchange again into the middle of a scenario as Call+Reply;
enter a qualified value, select both occurrences, Undo/Redo, save/reopen.
2. Insert inside an ALT/LOOP operand, reorder within it, reject a reply before its
call, and inspect correlated execution bars and preserved occurrence identities.
3. Cancel drawing after scrolling/layout change, retry, retain typed values while
navigation is blocked, and open/return from a referenced scenario. Verify at
1440x900 and 1100x700 on Windows and Linux before scoped acceptance.
## Initial automated evidence — 2026-09-06 (superseded by review fixes)
Source checkout: `D:/CodexBuilds/Capella2-scenario-direct`, production commit
`d1d8f0bbed5aa0112189138a1cc025affe26c09b`. Changes are confined to desktop
`app.rs`, `behavior.rs` and `behavior/scenario.rs`; no core model, persisted layout
or schema changes are included.
All commands used the dedicated `D:/CodexBuilds/Capella2-scenario-target` target
with `--locked --offline`:
- `cargo check -p oa-desktop --all-targets`: passed.
- `cargo test -p oa-desktop direct_`: 19 passed, including 13 new direct-scenario
tests and six existing matching tests.
- `cargo test -p oa-desktop`: final source passed all 274 tests, zero failures or
ignored tests. This includes exact source/model Undo, creation and reorder
selection, rejected stale contexts, retained failed-write drafts, bounded
contextual controls, scroll-origin cancellation and correlated activation spans.
- `cargo clippy -p oa-desktop --all-targets -- -D warnings`: passed.
- `cargo build -p oa-desktop --release`: passed.
- `git diff --check`: passed before the production commit.
Logs are under `D:/CodexBuilds/`: `Capella2-scenario-check.log`,
`Capella2-scenario-direct-tests.log`, `Capella2-scenario-desktop-final-tests.log`,
`Capella2-scenario-desktop-clippy.log` and `Capella2-scenario-desktop-release.log`.
Windows artifact:
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
20,345,856 bytes, SHA-256
`7CC6DCDB7ECC0C6B144137D31DB0BE9BC1555FEFAE6B465DAEABCF52DFE1A166`.
This artifact has not been native-tested. Widget-tree/event tests do not establish
visual usability, scrolling behavior or GPU rendering acceptance; the three native
tasks above remain required. Process/chain direct editing remains outside this
bounded implementation.
## Independent-review fixes and final automated evidence — 2026-09-06
Production commit: `7a666076bfcfd1f2258d2f5508328db5adf40825`, same isolated
checkout and dedicated target. Only the three previously owned desktop source
files changed. No core/source/schema or process/chain features were added.
The new event regressions failed on the prior implementation: a bounds shift kept
the first click's source/slot armed, and a held drag could still publish its old
slot after the bounds changed. Both now cancel. After scrolling, resizing or
clipping changes, the next click starts a fresh gesture. App tests also verify
unchanged-size notifications retain context, actual resize invalidates it, and
scrolling preserves property/source history and the pending behavior form.
Local Iced inspection established that Scrollable translates its supplied cursor
but leaves raw CursorMoved coordinates and the canvas layout unchanged. The
existing inner and outer scrollables now have a local, passive observer which
reads their actual translation through Widget::operate. Ordinary on_scroll cannot
report initial/remounted state or passive content-size clamping. The observer
forwards layout, drawing, overlays and event capture, and publishes only changed
translations or document/owner context. Reporting before child input makes a
result using an old slot stale before App processes it; reporting after input
captures wheel/scrollbar changes. The real Iced Scrollable test uses its debug
null renderer and verifies 0 → 180 wheel offset → 50 passive clamp → 180 on
expansion → 0 after remount, with no repeated messages on unchanged redraws.
The reported ALT fixture also failed before the fix: a root Call, Reply then
Action in operand one, another Reply in operand two, and a third operand without
a Reply. Derived execution geometry now splits at operand boundaries, stops each
returned operand at its own correlated Reply, leaves its following Action outside
that segment, and marks the reply-free operand open. It does not imply chronology
between alternative operands or add persistent execution records.
Picker measurement on the same Windows debug target:
| Probe | Before | After |
| --- | --- | --- |
| 500 compatible exchange choices | 7.87 s | 1.46 ms |
| Same choices after one name edit | 8.14 s | 1.48 ms |
| 100 exact-slot earlier calls, initial validation | not measured | 595 ms |
| Those calls after name/value/search/page edits | not measured | 34–39 µs |
The exchange list no longer clones and validates the entire project per candidate.
Final Apply still rejects a Delete inserted before a later Call on that lifeline;
the new regression confirms appending that same Delete succeeds. Reply choices
retain exact validation, invalidate for changed slot/document/revision/endpoints,
and do not lose their cached eligibility while expressions are being typed.
These are bounded picker measurements, not complete native-frame timings.
Final checks (`--locked --offline`): 27 focused matching tests passed; all 282
desktop tests passed, zero failures/ignored tests; strict desktop all-target
Clippy passed; optimized desktop build passed; git diff --check passed. Clippy
requested one test-only nested-if cleanup, which was applied before the final
clean Clippy/build.
Logs under `D:/CodexBuilds/`:
`Capella2-scenario-review-before-tests.log`, `Capella2-scenario-alt-before.log`,
`Capella2-scenario-scroll-observer-tests.log`,
`Capella2-scenario-review-desktop-tests.log`,
`Capella2-scenario-review-clippy.log`, `Capella2-scenario-review-release.log`.
Current Windows binary:
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
20,349,952 bytes, SHA-256
`1AE729097F267DE596AE0970D3A62AE06C0C89CE2FDE70DF980AD785A0E0A8E6`.
It supersedes the initial artifact above. Native acceptance is still pending.
Root should repeat the three primary native tasks, including inner/outer wheel
and scrollbar cancellation at a middle slot, resize/remount/clamp recovery, and
the three-operand ALT fixture. The broader release objective remains incomplete.
## Final passive-scroll save correction — 2026-09-06
Production `e7e37d485d842731d270781bd4e60a323fe4cdba` addresses the focused review's remaining App lifecycle
finding. During an async save, Iced can change its scroll position while App's busy
gate rejects ordinary messages. The observer had already recorded its report,
so dropping that notice left App's offset stale after save completion. The new
regression failed before the fix: App retained offset (0, 0) instead of (25, 140).
App now dispatches only the passive Scrolled event before interaction gates. Its
existing handler still validates document, scenario and revision. Mutation input
remains gated. The test checks current offsets and cancellation epoch while busy,
rejects an old-document observation, delivers a successful Saved receipt, then
uses production Canvas pointer events to create the intended sender/receiver/slot
draft without a model or source-history mutation.
The independent reviewer rechecked this exact production change and regression;
no remaining medium/high finding was reported in the focused review. Final full
desktop tests pass 283/283, strict all-target desktop Clippy passes, optimized
desktop build passes, and git diff --check passes. Logs under D:/CodexBuilds are
`Capella2-scenario-busy-scroll-before.log`,
`Capella2-scenario-busy-scroll-after.log`,
`Capella2-scenario-busy-scroll-desktop-tests.log`,
`Capella2-scenario-busy-scroll-clippy.log` and
`Capella2-scenario-busy-scroll-release.log`.
Latest Windows artifact, superseding both builds above:
`D:/CodexBuilds/Capella2-scenario-target/release/arcadia-studio.exe`,
20,349,952 bytes, SHA-256 `CEEB8E277DFC47C0D55DA43F903FCA45AFBF902AFA418B3924375910D699373A`.
Native acceptance is pending. Include scrolling while saving in the root's native
recovery task before accepting the direct scenario milestone.
## Combined DATA/scenario integration — 2026-09-06
Merge `dfd2e41259e73ef10a4e29a426569c08eaae215f` integrates `codex/data-contract-integration` at
`b35dede75fc8778ea695b9b2c023cfa2a1e49e09` into the isolated scenario branch.
The merge applied without conflicts and needed no manual code resolutions or new
semantic design. Direct scenario geometry/planners/tests and the existing
scenario evidence were unchanged by the merge. App's early passive-scroll handler
remains intact alongside DATA's source migration and property-browser handling.
The shared field-path API remains compatible: compact leaf-first labels and
deterministic sorting now feed the existing bounded scenario value picker, while
full qualified labels remain available for detail text.
Combined verification on the dedicated scenario target: all 286 desktop tests
passed, no failures or ignored tests; strict desktop all-target Clippy passed;
optimized desktop build passed; staged diff --check passed. All commands used
`--locked --offline`. DATA's three new regressions are present together with the
283-test scenario suite, including source3 save/reopen and direct gesture/save
lifecycle coverage. No DATA checkout, Linux checkout or native application was
modified or operated in this integration task.
Frozen artifact directory: `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`.
It contains `arcadia-studio.exe`, the three combined verification logs and a
`manifest.json` identifying the source checkout, merge commit, target and hash.
The copied binary hash was checked against the release target binary.
Binary size: 20,411,392 bytes. SHA-256: `8BC031CF6124859D31082BB0FFB0335C406D54E12ECAD8493CE50A23923E5589`.
Root reports DATA b35dede's Windows native trial and independent focused review
passed. This does not transfer native acceptance to the combined direct-scenario
workflow. Its Windows/Linux native tasks remain pending, including scroll/resize
cancellation, middle/operand insertion, execution spans and save recovery. Root
owns final integration into main and native acceptance; process editing remains
outside this task.