Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu b2ab2b1feb Prove R3 self-tagged facet reviews against carrier baseline
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-06 04:19:13 +03:00
sorokin.oleg.edu 8ac9f464ea Probe R3 typed facet carriers and independent order review 2026-09-06 03:58:48 +03:00
43 changed files with 4281 additions and 10916 deletions
+1 -3
View File
@@ -53,8 +53,6 @@ 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.
@@ -72,7 +70,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 4;
arcadia 3;
project harbor "Harbor assistance" {
oa_entity control "Harbor control" {}
oa_activity respond "Respond to requests" {
+5 -555
View File
@@ -12,219 +12,8 @@ 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;
@@ -326,7 +115,6 @@ pub enum Message {
BrowseGroup(crate::explorer::Group),
ExplorerPage(usize),
CreateKind(Kind),
Data(crate::data_view::Event),
Add(bool),
AddRelation,
PortAction(crate::port_workflow::Action),
@@ -339,9 +127,6 @@ 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,
@@ -359,8 +144,6 @@ 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,
@@ -421,7 +204,6 @@ 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,
@@ -510,7 +292,6 @@ impl App {
page: Page::Diagram,
guide: crate::guide::State::default(),
behavior: Default::default(),
data_state: Default::default(),
inspector_tab: Default::default(),
layer,
selected,
@@ -793,14 +574,9 @@ 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) = scenario_selection.or(selected) {
if let Some(id) = selected {
self.selected = id;
}
self.sync();
@@ -956,19 +732,6 @@ 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;
@@ -1001,8 +764,6 @@ impl App {
Message::Field(..)
| Message::FieldText(..)
| Message::FieldShape(..)
| Message::FieldAppend(..)
| Message::FieldValuePath(..)
| Message::FieldVariant(..)
| Message::FieldArray(..)
| Message::ElementKind(_)
@@ -1126,11 +887,6 @@ 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
@@ -1140,7 +896,6 @@ 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;
@@ -1173,116 +928,8 @@ 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()
@@ -1477,32 +1124,6 @@ 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| {
@@ -1763,19 +1384,6 @@ 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() {
@@ -1870,9 +1478,6 @@ 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) => {}
@@ -2598,16 +2203,7 @@ impl App {
Err(e) => e,
};
}
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::WindowResized(size) => self.window_size = size,
Message::CanvasResized(size) => {
let first_measurement = self.canvas_size.is_none();
let size_changed = self.canvas_size != Some(size);
@@ -2981,14 +2577,8 @@ impl App {
.discovered(&disk.bundle, disk.reachable.clone());
self.show_source_file(false);
self.source_diagnostics = self.document.compiled().diagnostics.clone();
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();
}
self.status =
"Source project opened. Included files and comments are preserved.".into();
}
Err(error) => {
self.canvas_document = Id::new_v4();
@@ -3618,9 +3208,7 @@ 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)
// 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)
&& decoded.migration.is_some()
{
return Ok(LoadedSnapshot::Legacy(
path,
@@ -3705,97 +3293,6 @@ 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");
@@ -4350,50 +3847,3 @@ 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,18 +322,14 @@ 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::topology::CanvasEvent::Viewport(context, iced::Size::new(900., 600.)),
behavior::topology::CanvasEvent::Zoom(context, Some(0.7)),
behavior::topology::CanvasEvent::Zoom(context, None),
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),
] {
send(app, Message::TopologyCanvas(event));
send(app, Message::Behavior(event));
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert!(app.draft_dirty());
assert!(!app.behavior.has_pending());
-591
View File
@@ -1,591 +0,0 @@
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
+413 -197
View File
@@ -1,16 +1,8 @@
//! Process/chain authoring and projection use contextual occurrences and explicit
//! SequenceLinks. Presentation order never invents control-flow relationships.
use super::*;
use iced::widget::{Column, text_input};
use iced::widget::{Column, 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 {
@@ -48,8 +40,9 @@ pub enum Event {
Guard(String),
AlternateGuard(String),
Control(ControlChoice),
Search(usize, String),
Page(usize, usize),
Viewport(Size),
Zoom(f32),
Fit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ControlChoice(pub ControlKind);
@@ -66,25 +59,11 @@ 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,
@@ -120,6 +99,9 @@ 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
@@ -145,9 +127,6 @@ impl State {
}
});
self.draft = Some(Draft {
insertion: Insertion::default(),
fixed: false,
browsers: Default::default(),
owner,
tool,
name: String::new(),
@@ -175,11 +154,6 @@ 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,
_ => {}
}
}
@@ -189,13 +163,12 @@ impl State {
}
fn scale(&self, graph: Size, fallback_width: f32) -> f32 {
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.)
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.)
})
}
}
fn is_contribution(p: &Project, owner: Id, id: Id) -> bool {
@@ -274,24 +247,13 @@ fn targets(p: &Project, owner: Id, tool: Tool) -> Vec<Choice> {
})
.map(|e| Choice {
id: e.id,
label: format!("{} · {}", e.name, semantic_path(p, e.id)),
label: format!("{} · {}", e.name, e.alias),
})
.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)
@@ -432,40 +394,6 @@ 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,
@@ -490,6 +418,7 @@ 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));
}
@@ -530,6 +459,12 @@ 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,
@@ -617,47 +552,30 @@ impl Draft {
commands.push(Command::CreateElement(e));
(id, id)
};
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(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(after) = self.after {
commands.push(link(p, last, after, ""));
}
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));
// 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)),
);
}
Ok(unique_batch(p, commands))
}
}
@@ -680,6 +598,23 @@ 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
@@ -687,9 +622,307 @@ fn event(e: Event) -> Message {
super::Event::Topology(e)
})
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
view::view(app, owner, out)
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, 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()
@@ -709,7 +942,6 @@ struct GraphNode {
source: Option<Id>,
preview: Vec<(Id, String)>,
preview_links: Vec<(Id, Id)>,
preview_total: usize,
}
struct GraphEdge {
id: Id,
@@ -719,21 +951,15 @@ struct GraphEdge {
points: Vec<Point>,
label: Rectangle,
}
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
struct Graph {
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
size: Size,
selected: Id,
scale: f32,
}
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 {
impl Graph {
fn new(p: &Project, owner: Id, selected: Id) -> Self {
let choices = contributions(p, owner);
let links = sequence_links(p, owner);
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
@@ -774,17 +1000,17 @@ impl<'a> Graph<'a> {
fallback += 1;
}
}
let mut rows: BTreeMap<usize, f32> = BTreeMap::new();
let mut rows: BTreeMap<usize, usize> = 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_insert(24.);
let slot = rows.entry(rank).or_default();
let mut node = GraphNode {
id: e.id,
rect: Rectangle::new(
Point::new(24. + rank as f32 * 420., *slot),
Point::new(24. + rank as f32 * 420., 24. + *slot as f32 * 255.),
Size::new(270., 104.),
),
title: e.name.clone(),
@@ -793,8 +1019,8 @@ impl<'a> Graph<'a> {
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, .. } => {
@@ -825,19 +1051,11 @@ impl<'a> Graph<'a> {
.map(|id| format!("ref · {}", p.named(id)))
.unwrap_or("Choose source in Properties".into());
if let Some(source) = sequence {
let contributions = contributions(p, *source);
node.preview_total = contributions.len();
node.preview = contributions
node.preview = contributions(p, *source)
.into_iter()
.take(if expanded.contains(&e.id) {
usize::MAX
} else {
4
})
.take(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)
@@ -853,13 +1071,11 @@ impl<'a> Graph<'a> {
_ => {}
}
}
*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()
@@ -873,14 +1089,11 @@ impl<'a> Graph<'a> {
.rect;
let start = Point::new(a.x + a.width, a.center_y());
let end = Point::new(b.x, b.center_y());
// 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 points = if end.x > start.x {
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 * 42.;
let below = size.height + 24. + i as f32 * 30.;
vec![
start,
Point::new(start.x + 18., start.y),
@@ -894,7 +1107,7 @@ impl<'a> Graph<'a> {
RelationBody::SequenceLink { guard, .. } => guard.clone(),
_ => String::new(),
};
let label_at = if forward {
let label_at = if end.x > start.x {
Point::new(end.x - 138., end.y - 43.)
} else {
Point::new(((start.x + end.x) / 2. - 66.).max(4.), points[2].y - 38.)
@@ -915,9 +1128,6 @@ impl<'a> Graph<'a> {
}
}
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
nodes,
edges,
size,
@@ -994,24 +1204,47 @@ 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 = Input;
impl canvas::Program<Message> for Graph {
type State = ();
fn update(
&self,
state: &mut Input,
_: &mut (),
event: &canvas::Event,
bounds: Rectangle,
cursor: mouse::Cursor,
) -> Option<Action<Message>> {
self.input(state, event, bounds, cursor)
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
}
fn draw(
&self,
state: &Input,
_: &(),
renderer: &Renderer,
_: &Theme,
bounds: Rectangle,
cursor: mouse::Cursor,
_: mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut f = Frame::new(renderer, bounds.size());
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
@@ -1094,7 +1327,7 @@ impl canvas::Program<Message> for Graph<'_> {
if n.source.is_some() {
draw_text(
&mut f,
format!("Source preview · {} / {}", n.preview.len(), n.preview_total),
"Open source →".into(),
Point::new(n.rect.x + 12., n.rect.y + 94.),
11.,
style::TEAL,
@@ -1167,12 +1400,11 @@ 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 {
@@ -1246,9 +1478,6 @@ 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(),
@@ -1437,7 +1666,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!(
@@ -1548,26 +1777,10 @@ mod tests {
draft: Some(branch(owner, a, b, before, after)),
..State::default()
};
state
.update(
&p,
Event::Target(Choice {
id: Id::nil(),
label: "Missing".into(),
}),
)
.unwrap();
state.update(&p, Event::Guard(String::new())).unwrap();
assert!(state.update(&p, Event::Submit).is_err());
assert!(state.draft.is_some());
state
.update(
&p,
Event::Target(Choice {
id: a,
label: "Work".into(),
}),
)
.unwrap();
state.update(&p, Event::Guard("ready".into())).unwrap();
let command = state.update(&p, Event::Submit).unwrap().unwrap();
assert!(state.draft.is_some());
assert!(matches!(command, Command::Batch(_)));
@@ -1623,8 +1836,9 @@ mod tests {
.unwrap();
let mut graph = Graph::new(&p, owner, owner);
let mut state = State::default();
state.viewport(Size::new(486., 460.));
state.zoom(None);
state
.update(&p, Event::Viewport(Size::new(486., 460.)))
.unwrap();
graph.scale = state.scale(graph.size, 500.);
assert!(graph.size.width * graph.scale <= 462.1);
assert!(graph.size.height * graph.scale <= 450.1);
@@ -1635,7 +1849,7 @@ mod tests {
);
let result = canvas::Program::update(
&graph,
&mut Input::default(),
&mut (),
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Rectangle::new(Point::ORIGIN, Size::new(486., 460.)),
mouse::Cursor::Available(click),
@@ -1644,10 +1858,12 @@ mod tests {
.into_inner()
.0;
assert!(matches!(result, Some(Message::Select(id)) if id == node.id));
state.zoom(Some(1.));
state.viewport(Size::new(800., 460.));
state.update(&p, Event::Zoom(1.)).unwrap();
state
.update(&p, Event::Viewport(Size::new(800., 460.)))
.unwrap();
assert_eq!(state.scale(graph.size, 500.), 1.);
state.zoom(None);
state.update(&p, Event::Fit).unwrap();
assert!(state.scale(graph.size, 500.) < 1.);
}
}
@@ -1,747 +0,0 @@
//! 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.),
})
}
}
@@ -1,970 +0,0 @@
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);
}
}
}
@@ -1,478 +0,0 @@
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)),
)
}
+5 -6
View File
@@ -432,12 +432,11 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface { items: vec![item] };
}
p.relate(
RelationKind::InterfaceProvision,
-559
View File
@@ -1,559 +0,0 @@
//! 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
+1 -181
View File
@@ -292,7 +292,6 @@ 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;
@@ -370,33 +369,6 @@ 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) {
@@ -753,10 +725,7 @@ 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)
.or_else(|| self.hit_association_label(&scene.routed, p))
{
} else if let Some(id) = self.hit_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
@@ -1041,37 +1010,6 @@ 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) {
@@ -1319,17 +1257,6 @@ 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,
@@ -1617,21 +1544,6 @@ 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) {
@@ -2441,98 +2353,6 @@ 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 {
+13 -142
View File
@@ -14,7 +14,6 @@ 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)]
@@ -32,12 +31,6 @@ 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,
@@ -46,7 +39,6 @@ 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 {
@@ -84,14 +76,11 @@ 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 {
@@ -229,7 +218,7 @@ impl Draft {
} else if path.ends_with("bindings") {
json!({"referenced": Id::nil(), "local": Id::nil()})
} else if path.ends_with("values") {
json!({"path": [], "field": Id::nil(), "expression": ""})
json!({"field": Id::nil(), "expression": ""})
} else if path.ends_with("routes") {
json!({"edge": Id::nil(), "waypoints": [], "label": null})
} else if path.ends_with("waypoints") {
@@ -253,33 +242,6 @@ 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();
@@ -449,7 +411,6 @@ fn reference(path: &str) -> bool {
| "Named"
| "diagram"
| "machine"
| "supertype"
) || matches!(
parent,
"items"
@@ -479,7 +440,6 @@ fn optional_reference(path: &str) -> bool {
| "reply_to"
| "lifeline"
| "state"
| "supertype"
)
}
fn multiline(path: &str) -> bool {
@@ -502,11 +462,9 @@ fn free_string(path: &str) -> bool {
multiline(path)
|| matches!(
path.rsplit('/').next(),
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
Some("name" | "uri" | "revision" | "fragment" | "unit")
)
|| 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.
@@ -704,42 +662,21 @@ 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),
message_kind,
..
exchange: Some(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()
}),
} => project.relations.get(id),
_ => None,
})
.unwrap_or_default()
.into_iter()
.filter(|choice| choice.key.path == address_path)
.map(|choice| choice.key.field)
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
.map(|r| validation::carried_fields(project, r).into_iter().collect())
.unwrap_or_default();
}
let scenario = element
.as_ref()
.and_then(|e| e.parent)
@@ -770,40 +707,6 @@ 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
@@ -1155,20 +1058,6 @@ 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",
@@ -1177,7 +1066,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"],
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
"kind" if path.contains("State") => &[
"State",
@@ -1243,20 +1132,11 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
if field == "definition" {
return vec![
("Boolean".into(), json!("Boolean")),
(
"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}}),
),
("Integer".into(), json!("Integer")),
("Real".into(), json!("Real")),
("Text".into(), json!("String")),
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
("Record".into(), json!({"Record":{"supertype":null}})),
("Record".into(), json!("Record")),
];
}
if field == "type_ref" {
@@ -1393,9 +1273,6 @@ 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);
@@ -2198,8 +2075,6 @@ mod tests {
Layer::Oa,
SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
mechanism: ExchangeMechanism::Flow,
supertype: None,
},
);
let mut draft = Draft::from_project(&p, item).unwrap();
@@ -2216,12 +2091,8 @@ mod tests {
let interface = p
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
.unwrap();
p.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
p.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
assert!(draft.dirty());
assert_eq!(p.elements[&item].layer(), Layer::Oa);
-813
View File
@@ -1,813 +0,0 @@
//! 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));
}
}
+9 -7
View File
@@ -144,7 +144,13 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
}
let declared: Vec<_> = interfaces
.iter()
.flat_map(|id| data::interface_items(project, *id))
.filter_map(|id| project.elements.get(id))
.filter_map(|e| match e.shared() {
Some(SharedBody::Interface { items }) => Some(items),
_ => None,
})
.flatten()
.copied()
.collect();
items_satisfy(project, items, &declared)
}
@@ -708,12 +714,8 @@ mod tests {
interface,
);
assert!(reference_ids(&draft, &project, target).is_empty());
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
item,
);
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
}
+7 -24
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 nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
"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 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.",
"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.",
),
],
related: &[
@@ -510,34 +510,17 @@ 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 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",
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
sections: &[
walkthrough(
"Define a reusable payload",
&[
"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.",
"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.",
"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",
&[
@@ -740,7 +723,7 @@ fn article(topic: Topic) -> Article {
),
explain(
"Open and migration",
"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.",
"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.",
),
],
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
-2
View File
@@ -4,8 +4,6 @@ mod behavior;
mod connection_edit;
mod connection_tool;
mod context_menu;
mod data_values;
mod data_view;
mod diagram;
mod document;
mod explorer;
+13 -20
View File
@@ -157,9 +157,12 @@ 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 {
for item in data::interface_items(project, interface) {
origins.entry(item).or_default().push(format!(
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!(
"preserved interface {} (suggestion only; not an active typed contract)",
context.label(interface)
));
@@ -708,17 +711,11 @@ 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.relate(
RelationKind::InterfaceItemAllocation,
"First item",
interface,
first,
);
project.relate(
RelationKind::InterfaceItemAllocation,
"Second item",
interface,
second,
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
Layer::Sa,
SharedBody::Interface {
items: vec![first, second],
},
);
let connection = &project.relations[&boundary];
let (a, b) = (connection.source(), connection.target());
@@ -793,12 +790,8 @@ mod tests {
..
} = fixture();
let interface = add(&mut project, Kind::Interface, "Preserved contract");
project.relate(
RelationKind::InterfaceItemAllocation,
"Allocated item",
interface,
first,
);
project.elements.get_mut(&interface).unwrap().body =
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
let legacy = port(
&mut project,
source,
+6 -3
View File
@@ -168,9 +168,12 @@ impl State {
_ => {}
}
}
if let Some(interface) = old.and_then(|old| old.interface) {
let items = data::interface_items(p, interface);
incoming.extend(items.iter().copied());
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);
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 4;"));
assert!(app.source_snapshot.starts_with("arcadia 3;"));
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
}
+63 -46
View File
@@ -703,7 +703,69 @@ fn inspector(app: &App) -> View<'_, Message> {
}
fn data_catalog(app: &App) -> View<'_, Message> {
crate::data_view::view(app)
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()
}
fn traces(app: &App) -> View<'_, Message> {
@@ -1037,51 +1099,6 @@ 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,420 +315,3 @@ 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;
}
+3 -4
View File
@@ -984,10 +984,9 @@ 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. 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>
no items. Item compatibility is identity equality; future specialization is
not inferred from names. 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
+3 -4
View File
@@ -311,10 +311,9 @@ 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. 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.
no items. Item compatibility is identity equality; future specialization is
not inferred from names. 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 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 |
| 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 |
| 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 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 |
| 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 |
| 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 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.
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.
## 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-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.
`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.
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 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.
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.
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.
+106
View File
@@ -0,0 +1,106 @@
# C: finite typed facet reviews — executable comparison
Status: isolated proof, pending root freeze. Production remains at `959b83c78fcaa82b0184aade81a0508b47eb1e41`; B1 evidence is commit `8ac9f464eaf0ec813ee3ecf903404891ae2a3841`. No R6/schema/source contract changed.
## Result and proposed shape
**11 tests pass: the original 3 B1 tests plus 8 C tests.** C stores reviewed facts directly, without an own-record carrier, reviewed mask, order shadow, observation version or recursive value tree. `OwnRecord` is reused only for transient source/target candidates and existing fixture helpers. The exact types/adapter are in `src/typed_reviews.rs`; assertions are in `src/typed_reviews_tests.rs`.
```rust
enum Observed<T> { NotApplicable, Value(T) }
struct Reviewed<T> { oa: Observed<T>, sa: Observed<T> }
struct KeyPair<K> { oa: Option<K>, sa: Option<K> }
struct ValueKey { path: Vec<Id>, field: Id }
struct Baseline { facets: Vec<FacetReview> }
```
`FacetReview` is self-tagged: Data family leaves carry `Reviewed<Option<i64>>`, `Reviewed<Option<f64>>`, `Reviewed<Option<DataValue>>`, etc.; the tag identifies the facet. Other proof leaves are field minimum, state guard, mapped trigger/step membership, step order, atomic keyed annotations and atomic keyed bindings. Existing `MessageValue`, `LifelineBinding`, `DataValue` and `DataTypeDefinition` are reused. `T` is a concrete closed type in each variant, not an arbitrary recursively nested value.
An absent entry means Unreviewed. `Value(None)` means an applicable optional slot has no value (for example an unbounded Real minimum). `NotApplicable` means a selected constructor branch does not exist on that side. A mixed review keeping an SA Integer while acknowledging an OA Real records Real-minimum OA=`Value(None)`, SA=`NotApplicable`; Integer-maximum records the reverse applicability. A both-side-inapplicable leaf is rejected.
Recommendation remains **C for the full contract**, because facts are explicit and independent. This proof does **not** show fewer lines of implementation: see the measured cost below. B1 remains viable at restricted granularity; C removes its storage-overlap invariants rather than eliminating exhaustive adapter work.
## What the executable proof establishes
| Case | Observed assertions |
| --- | --- |
| Bounds/unit and field minimum | Keep acknowledges only selected facts; unselected maximum still drifts. Take maximum retains target unit/minimum. Field minimum retains target maximum. Repeat application is unchanged. |
| Guard/triggers after deletion | Deleted source UUID remains in a typed historical key. Removal unlinks the mapped trigger but leaves its target object; extra target trigger and local guard survive. |
| Ordered steps | Accepting membership leaves the order fact byte-for-byte unchanged. Explicit final order must contain all surviving target members exactly once. Target-only step object survives unchanged. |
| Qualified annotation rows | Two paths with the same terminal latitude UUID remain separate. Updating/removing the first row does not acknowledge or overwrite the second row's expression. Removal acknowledges the whole selected row. |
| Binding rows | Key is the referenced lifeline; changing `local` updates that row through frozen mapping. Other binding remains unchanged. Missing local mapping rejects. |
| Mixed constructor group | Keep can acknowledge Real/Integer as intentionally different constructors, including correct applicable/not-applicable leaves. Take changes definition+default while preserving unit. A later explicit target switch can reconsider a previous Keep. Retained Record child blocks an incompatible switch atomically. |
| Prepared induced changes | A real inherited-field removal causes core `prepare_command` to prune annotations. C compares the actual prepared project with the reviewed expected project and rejects these unreviewed changes. Project and baseline inputs remain unchanged. |
| Adversarial admission | Exact duplicates, keys colliding on either side, mismatched frozen key mapping, changed pairing against old baseline, wrong-family/both-side-NA Keep, unchanged-constructor acknowledgement, invalid historical row/key combinations, empty key pairs and nonfinite values reject. Collision permutations both reject. Returned baseline is revalidated. |
Constructor grouping copies the definition and default in this subset; accepting a switch retires old definition facts and records the union of both resulting constructor branches. Unit stays independent. An unchanged constructor cannot be used as a shortcut to acknowledge changed bounds/defaults. Constructor and descendant decisions in one request reject as overlapping. Membership operations precede order regardless of request order.
Pair remapping is deliberately conservative: a new KeyPair colliding with an old baseline pair rejects with an explicit review-invalidation requirement. This probe does not implement the subsequent mapping-reset workflow or agreement-based many-to-one merges. It cannot silently accumulate a baseline that fails on its next application.
## Measured implementation cost
`measure-code.ps1` counts nonblank, non-comment rustfmt lines. B1 is read from its committed version, not a moving file. These are code-size measurements, not a normalized complexity or speed benchmark.
| Portion | B1 | C | Difference |
| --- | ---: | ---: | ---: |
| Public types/requests | 69 | 118 | +49 |
| Adapter/admission/application | 256 | 778 | +522 |
| Tests | 512 | 1,035 | +523 |
Both use the same 52 lines of basic own-record/list helpers; C does not call B1's carrier copy/compare/apply logic. C covers 18 selector cases versus B1's 9, including both keyed row kinds and all DataType constructor branches. C also adds admission and prepared-project checks absent from B1. Therefore attributing the entire difference to representation would be misleading. C's repeated capture/tag/write matches are real maintenance cost; one finite catalog should supply selector/type metadata during implementation, without introducing a general metamodel framework.
## Full coverage and minimal granularity recommendation
| Family | Facets required by the current model |
| --- | --- |
| Headers | Name, description, element parent. No incidental identity/alias/layer edits. |
| OA/SA primitive bodies | No payload; Process/FunctionalChain step membership and order. |
| ExchangeItem / Interface | Nature, mechanism, supertype; interface supertype. Allocations remain real relation records. |
| DataType | Constructor, integer/real bounds, string lengths, enum literal membership, Record supertype, default, unit. |
| DataField | Whole TypeRef, multiplicity bounds, unit, direction, storage, whole optional default vector. No unstable per-index default selection. |
| Port | Contract constructor; input/output item membership; component kind/orientation; legacy direction/interface. Existing typed/Legacy resolution and split semantics remain authoritative. |
| Scenario / Lifeline | Viewpoint, context, pre/postconditions, step membership/order; represented reference. |
| Occurrence envelope | Constructor and separate common pre/postconditions. |
| Exchange occurrence | Exchange, sender, receiver, message kind/Timer expression, reply correlation, annotations keyed by full `(path,field)`. |
| Remaining occurrences | Action/action-lifeline; control kind; sequence reference; scenario reference/bindings; state/state-lifeline; fragment operator/branches; branch guard/ordered occurrences. |
| Event / State | Event constructor and Signal items/Time kind+expression/Change expression; StateKind structural change and entry/exit/do prose. Machine/Region have no own payload. |
| Every relation | Source and target endpoints, plus name/description. |
| Relation payloads | Interface allocation order/protocols; association roles/multiplicities/navigation; exchange items/ports/orientation; capability-extend condition; sequence guard; transition kind/trigger+effect sets and three prose fields; StateAction phase. All other relation kinds are endpoint-only. |
| External membership/artifacts | Direct children; incident relation identity+endpoint role; attached artifact membership and name/description/owner/relation/URI/revision/fragment. |
Atomic annotation and binding rows are the smallest useful contract: key changes are explicit remove/add, while all other rows remain independent. A separate expression-versus-presence checkpoint is unnecessary at this granularity. If such controls are later required, C can add separate typed facts without a second carrier state.
Steps/branch occurrences are ordered. Trigger alternatives, bindings and annotations must not accidentally acquire execution ordering semantics from vector position. Enum literals need string-keyed membership to preserve target-only literals; standalone whole-list `TakeSource` is explicitly rejected by this probe, pending that finite selector. A constructor conversion that would overwrite retained target enum literals also requires an explicit candidate. Field default vectors remain atomic.
Constructor scopes own discriminator and introduced/removed variant fields. Dependent changes outside that scope require explicit resolutions. In particular, changing Occurrence's constructor must not acknowledge its outer pre/postconditions; Call→Timer must expose exchange/reply/value effects; StateKind is semantic even though it changes `Element::kind()`. Prepared induced changes must be reviewed for every representation.
Current Trace endpoints/properties do not accept artifacts. Prefer attached artifact review facts owned by the semantic trace instead of silently widening trace compatibility. Missing artifact versus present artifact with `revision=None` needs distinct typed observations.
## Reuse the existing source decoder, with typed context
Baseline JSON roundtrips, including deleted historical UUIDs and mixed constructor observations. All accepted live candidates roundtrip through current Arcadia 4 source. **C baseline source syntax is not implemented or proved.**
The concrete reuse route is:
1. `language.rs::Parser::value_inner`: recognize a distinct historical `uuid "..."` AST literal. Do not convert it to an ordinary quoted string too early.
2. `Ast::resolve`: accept an explicit slot context supplied by the finite facet catalog. Historical reference slots accept UUID literals; live reference slots keep `@alias` requirements. A new context must be entered only through the semantic-baseline subtree of a Trace declaration.
3. Keep `resolve_declarations` and its existing `serde_json::from_value::<Trace>`/body decoding as the typed authority. Do not duplicate the live body factory/default merge logic for historical facts.
4. `write_fields`/`write_value`: use the same slot metadata to emit historical UUIDs without current alias lookup. Generic `Reviewed<T>{oa,sa}` cannot pass through current field-name-only rules: `oa`/`sa` are currently classified as references even when T is a unit string. Renaming keys alone does not distinguish reference/string/enum types.
5. `edit_positions`/`EditKind` and `source_project.rs::item_key`: retain full UUID token spans, historical identity remapping, and full annotation/binding keys for comment ownership.
6. Extend finite historical validation/admission counts and explicit remap visitors; exclude baseline IDs from live dangling-reference and deletion traversal. Historical carrier/facet validation must not fetch old objects or impose cross-facet live range invariants.
This needs explicit schema/source migration and OpenAPI descriptions before acceptance. No source4 or data4 changes were made here.
## Commands, evidence, and stopping point
From `D:/CodexBuilds/Capella2-transition-probe`:
```powershell
cargo test --manifest-path experiments/r3-facet-carrier/Cargo.toml --locked --offline --config target/r3-probe/cargo.toml --target-dir D:/CodexBuilds/Capella2-transition-probe-target -- --nocapture 2>&1 | Tee-Object -FilePath target/r3-probe/c-tests.log; exit $LASTEXITCODE
```
Exit 0: **11 passed, 0 failed**, no compiler warnings. The vendor config is unchanged from the B1 report. `& experiments/r3-facet-carrier/measure-code.ps1` reproduces counts. Logs, `code-counts.json` and generated `evidence/c-*-baseline.json` / `c-*-candidate.arcadia` remain under `target/r3-probe/`.
Still outside this proof: complete facet catalog; reverse direction; multiple target records; historical identity remapping; mapping-reset and agreeing many-to-one resolution; unambiguous automatic ordering; source baseline parser/formatter; OpenAPI/migration; size budgets for every new fact; and production baseline+undo/revision/ETag/disk atomicity. String literal/row keys and historical UUID admission need full adversarial coverage at implementation time. No claim of full R3 implementation or acceptance is made.
Stop here for the root's freeze decision. No native UI or other checkout/target was used.
+396
View File
@@ -0,0 +1,396 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "oa-core"
version = "0.2.0"
dependencies = [
"same-file",
"serde",
"serde_json",
"tempfile",
"thiserror",
"uuid",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "r3-facet-carrier-probe"
version = "0.0.0"
dependencies = [
"oa-core",
"serde",
"serde_json",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1_smol"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "uuid"
version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [
"getrandom",
"js-sys",
"serde_core",
"sha1_smol",
"wasm-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "r3-facet-carrier-probe"
version = "0.0.0"
edition = "2024"
publish = false
[workspace]
[dependencies]
oa-core = { path = "../../crates/oa-core" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+118
View File
@@ -0,0 +1,118 @@
# R3 facet carrier experiment
Status: isolated design/probe, not R3 implementation or an accepted persistence contract.
Follow-up: [C-REPORT.md](C-REPORT.md) contains the executable self-tagged typed-facet comparison, current recommendation, additional adversarial cases and measured code costs. This document preserves the earlier B0/B1 analysis.
Base: `959b83c78fcaa82b0184aade81a0508b47eb1e41`. Worktree: `D:/CodexBuilds/Capella2-transition-probe`, branch `codex/transition-probe`. The standalone crate depends on the unchanged local `oa-core`. It changes no accepted data4 shape or production code.
## Finding
Prefer **B: existing typed own-record carriers plus finite facet selectors** for further review. A carrier holds independently acknowledged values, not a coherent historical model. This removes the main reason to add immutable observation histories/cursors. One narrow extension is justified by a demonstrated overlap: store a separately acknowledged order vector for each ordered collection.
This is a proposal to investigate further. The probe covers three concrete cases, not every model constructor, mapping direction, source baseline format, or transition command lifecycle.
| Design | Benefits | Cost or failure demonstrated |
| --- | --- | --- |
| A: `SemanticPath(Vec<String>)` + recursive `SemanticValue` | Each leaf has independent storage; arbitrary nested values can be presented uniformly | Adds a second public model of numbers, enums, records, lists, references, and absence. Every read/write still needs an exhaustive typed adapter and closed path catalog. Wrong path/value combinations become runtime errors. Constructor groups, mapping, collection deltas, and validation remain necessary. Its flexibility supplies no demonstrated benefit for the three cases. |
| B0: one typed carrier per side + reviewed selectors | Reuses `ElementBody`, `RelationBody`, serde validation of shapes, and existing payload types; handles partial scalar review and historical references | **Insufficient for independently acknowledged membership and order.** A new step appended to the carrier can falsely acknowledge its pending placement. |
| B1: B0 + typed `OrderCheckpoint` | Preserves the older reviewed order while independently changing member presence; passes the counterexample | Duplicates only an order's identity vector. Other overlapping projections must be identified before adoption. |
| Immutable observation pairs + facet cursors | Preserves actual whole-record observations and independently selects review epochs | Adds observation IDs/indexes, retention, compaction, duplicate snapshots, and cursor validation. Mixed-time carrier values are legitimate, so the tested scalar cases do not justify this machinery. Order needs only its own typed checkpoint. Rejected for now, not proved universally unnecessary. |
A is not intrinsically incorrect. The rejection is unnecessary public representation and validation cost for a closed typed model, not a claim that it cannot implement the semantics.
## Smallest public shape under test
The exact compiled types are in `src/lib.rs`. They deliberately leave current prose `TransitionField::{Name,Description}`, `FieldDecision::Value(String)`, and `TracedProperties` unchanged. A production extension could add `TraceBaseline.semantic: Option<SemanticBaseline>` and `TransitionField::Semantic(Facet)`; old absent semantic baselines remain Unreviewed.
```rust
enum OwnRecord {
Element { parent: Option<Id>, body: ElementBody },
Relation { body: RelationBody },
}
struct MemberPair { oa: Option<Id>, sa: Option<Id> }
struct SemanticBaseline {
oa: OwnRecord,
sa: OwnRecord,
reviewed: Vec<Facet>,
orders: Vec<OrderCheckpoint>,
}
struct OrderCheckpoint {
collection: OrderedCollection,
oa: Vec<Id>,
sa: Vec<Id>,
}
enum OrderedCollection { Steps } // finite catalog to extend
enum Facet {
IntegerMinimum, IntegerMaximum, DataUnit, FieldMinimum,
StateGuard, Trigger(MemberPair), Step(MemberPair), StepsOrder,
DataConstructor,
}
enum Decision { TakeSource, KeepTarget, Candidate }
struct Resolution { facet: Facet, decision: Decision }
struct Review {
source: Id,
target: Id,
candidate: Option<OwnRecord>,
resolutions: Vec<Resolution>,
}
```
`Facet` is the closed proof subset, not a proposed complete catalog. A final catalog needs typed enum branches for element/relation scalar fields, parent/endpoints, reference members, order, and constructor scopes. It must not accept string paths. Message annotations need their existing `(path, field)` member identity; binding keys and other non-UUID members likewise need a finite typed key variant. `MemberPair` uses fixed OA/SA coordinates so reverse transition does not rename persistent selectors. At least one side must exist; conflicting pairs must be rejected. Historical pairs permit removal deltas even after a source object and its ordinary trace have been deleted.
All carrier types derive `PartialEq`; bodies can contain finite floats. Runtime admission must reject duplicate selectors, invalid selector/body combinations, non-finite values and excessive sizes. It must **not** impose live graph existence or cross-facet invariants on historical carriers. A baseline with independently acknowledged minimum=120 and maximum=100 is valid history of those two facets even though that combination was never a valid live range. Unreviewed slots are carrier storage, not evidence of acknowledgement.
`DataConstructor` is a grouped decision: the probe copies definition and default together. Production drift should compare constructor discriminants separately from descendant values; accepting a switch must refresh the complete new constructor group and retire old descendant selectors. That full catalog/acknowledgement expansion is not implemented by this subset. Constructor scope does not include unrelated unit/prose or silently delete target children.
A typed candidate is supplied once per target record, not a recursive merged value per leaf. The adapter selects only acknowledged facets. The probe reconstructs the expected final record and rejects a supplied candidate that changes any unselected slot. Identity/alias are absent; the existing body layer/kind must remain consistent with the explicit mapping. Full model validation still decides whether all accepted choices can coexist.
Preview can carry `source_before`, `source_now`, `target_before`, `target_now`, and `candidate` as `OwnRecord`, plus finite changed-facet rows. The GUI reads typed selected slots from these carriers. Historical `before` carriers must be labeled as per-facet reviewed values, never as a single past project state. Existing prose `FieldChange` can stay unchanged; a sibling semantic review payload avoids replacing its strings with a new value language.
## Executed cases
1. **Bounded data and field multiplicity.** Source integer 0..100 m becomes 120..200 cm; target remains 0..100 ft. Keep minimum and unit acknowledges those facets only. The source carrier intentionally becomes 120..100, while maximum still reports drift. Taking maximum produces target 0..200 ft. A second application is unchanged. Taking a field minimum preserves its independently larger target maximum. Replacing a target Record with Integer is rejected atomically while its target-only field remains; applying an Integer-minimum selector across Record/Integer constructors also rejects.
2. **State guard and triggers.** Delete the old OA Event; source then points to a new Event and changes guard. Target has an extra trigger and its own guard. Keep guard, take old-trigger removal and new-trigger addition. The historical `MemberPair` still identifies the removed source reference. Result preserves target-only trigger and guard, unlinks the old mapped target trigger, and leaves the target Event object alive. Reapplication is unchanged. Baseline JSON still roundtrips the deleted UUID.
3. **Ordered steps.** Source reverses A/B while target inserts independent step X. Take-source ordering reports that an explicit final order is needed. Candidate `[B,A]` rejects because X is missing; `[B,X,A]` passes and X's record stays byte-for-byte equal. Duplicate resolutions and a hidden parent edit reject. The same test proves the B0 counterexample: starting from `[A,B]`, independently acknowledging appended C changes the carrier to `[A,B,C]`, falsely hiding unreviewed order. B1 keeps order checkpoint `[A,B]` and reports the pending change.
The merge functions are intentionally small OA→SA experiments. They use real `prepare_command(Command::Batch(...))` validation before returning either the project or updated baseline, so the rejected constructor leaves both inputs intact. The baseline is outside the real Project; undo/redo, revision, ETag, fingerprint and on-disk atomic baseline integration are not claimed proved.
## Source and JSON parity implication
The tests prove serde JSON roundtrip for the new carrier and for each live candidate. Each live candidate also roundtrips through the existing `language::format` / `language::parse` typed Arcadia 4 implementation. No public arbitrary value tree or escaped JSON blob is persisted by this proposal.
**They do not prove persisted R3 baseline source parity.** The source language currently resolves live body reference fields through `@alias` and rejects a quoted UUID there (`language.rs`, `Ast::resolve`). A negative test transplants the deleted-Event historical body into a live declaration: its formatted source is rejected. This demonstrates the need for an explicit historical context; inventing a live stub or alias would alter semantics.
The smallest integration route is to reuse the existing typed body serde decoder/constructor catalog inside a new baseline-carrier source context. In that context, references should use a dedicated literal such as `uuid "..."`, resolve directly to `Id`, and bypass live-reference existence checks. Context is essential: a live `@alias` must not become valid after its object disappears, and historical carrier fields must not be pruned by ordinary deletion. The same historical visitor must explicitly remap UUIDs during real identity remapping, including member pairs and order vectors. A generic field-name-only decoder cannot distinguish these meanings.
The new wrapper/cursor-free types still require source constructor registration, historical token ownership/comment behavior, OpenAPI declarations, resource budgets, and an explicit schema/source migration. Accepted data4 stays unchanged in this experiment. Whether all current typed decoder logic can be reused without duplication needs a targeted follow-up inspection; this report does not assume that work is already solved.
## Open questions before adoption
- Exhaustive scalar/reference/constructor coverage and overlap audit. The proof's `DataConstructor` comparison is a group-copy shortcut; production needs separate discriminant drift and complete descendant acknowledgement. Other composite fields may need similar finite split storage, not a new arbitrary history mechanism by default.
- Per-member edits to annotated records, bindings and repeated occurrence contributions. Stable identity is not always a single UUID. Ownership and incident-relation membership plus artifact facets need explicit typed storage outside the own body.
- Automatic ordering when anchors are genuinely unambiguous. The probe conservatively requires an explicit order; it does not implement the contract's allowed automatic insertions.
- New/changed mappings, many-to-one conflict agreement, reverse direction, deleted mapped targets, historic remapping, missing/ambiguous reference counterparts, and validation of client-supplied masks and member pairs.
- Coherent constructor transitions with target-only dependents: one blocking example passed, but successful transitions and every nested constructor group still need coverage.
- Canonical processing of membership before order and constructor groups before descendants, independent of request array order. The bounded probe consumes resolutions in request order.
- Drift caused by shared referenced records, child membership rollups, bounds on historical membership, migration from old prose-only baselines, and production atomic baseline/history/source persistence.
## Reproduce and evidence
Run from the worktree in PowerShell. The standalone lockfile is included; the vendor configuration is generated locally and points read-only to the pre-existing vendor directory. The target belongs only to this experiment.
```powershell
cargo test --manifest-path experiments/r3-facet-carrier/Cargo.toml --locked --offline --config target/r3-probe/cargo.toml --target-dir D:/CodexBuilds/Capella2-transition-probe-target -- --nocapture
```
Result: **3 passed, 0 failed**. The final captured command additionally pipes output through `Tee-Object -FilePath target/r3-probe/tests.log` and exits with `$LASTEXITCODE`.
`target/r3-probe/cargo.toml`:
```toml
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "D:/CodexBuilds/Capella2-01a06e2e/vendor"
```
Evidence is under `target/r3-probe/`: `tests.log`, and generated `evidence/*-baseline.json` / `*-candidate.arcadia` for mixed range, deleted trigger and target-only step. Generated IDs differ between runs; the asserted behavior does not depend on them. No native UI was used, no package installed, and no other checkout or build target was changed.
@@ -0,0 +1,28 @@
# Run from the isolated worktree root. Counts rustfmt lines, not complexity.
$r3B = @(git show 8ac9f46:experiments/r3-facet-carrier/src/lib.rs)
$r3C = @(Get-Content -LiteralPath experiments/r3-facet-carrier/src/typed_reviews.rs)
$r3Tests = @(Get-Content -LiteralPath experiments/r3-facet-carrier/src/typed_reviews_tests.rs)
function Count-R3Lines($Lines) {
@($Lines | Where-Object { $_.Trim().Length -gt 0 -and -not $_.Trim().StartsWith('//') }).Count
}
function Find-R3Line($Lines, $Prefix) {
for ($r3Index = 0; $r3Index -lt $Lines.Length; $r3Index++) {
if ($Lines[$r3Index].StartsWith($Prefix)) { return $r3Index }
}
throw "Missing section marker: $Prefix"
}
$r3BOwn = Find-R3Line $r3B 'pub fn own('
$r3BCopy = Find-R3Line $r3B 'fn copy('
$r3BTests = Find-R3Line $r3B '#[cfg(test)]'
$r3CAdapter = Find-R3Line $r3C '// ADAPTER:'
$r3CTestStart = Find-R3Line $r3C '#[cfg(test)]'
[ordered]@{
method = 'Nonblank non-comment rustfmt lines; excludes test module registration'
b_public = Count-R3Lines $r3B[5..($r3BOwn-1)]
shared_record_helpers = Count-R3Lines $r3B[$r3BOwn..($r3BCopy-1)]
b_adapter = Count-R3Lines $r3B[$r3BCopy..($r3BTests-1)]
b_tests = Count-R3Lines $r3B[($r3BTests+1)..($r3B.Length-1)]
c_public = Count-R3Lines $r3C[5..($r3CAdapter-1)]
c_adapter = Count-R3Lines $r3C[$r3CAdapter..($r3CTestStart-1)]
c_tests = Count-R3Lines $r3Tests
} | ConvertTo-Json
+934
View File
@@ -0,0 +1,934 @@
//! Throwaway R3 experiment. Nothing in oa-core depends on this crate.
//! A carrier stores independently reviewed facets; it is NOT a past valid model.
use oa_core::*;
use serde::{Deserialize, Serialize};
pub mod typed_reviews;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum OwnRecord {
Element {
parent: Option<Id>,
body: ElementBody,
},
Relation {
body: RelationBody,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemberPair {
pub oa: Option<Id>,
pub sa: Option<Id>,
}
/// Deliberately finite proof subset, not the final exhaustive R3 catalog.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Facet {
IntegerMinimum,
IntegerMaximum,
DataUnit,
FieldMinimum,
StateGuard,
Trigger(MemberPair),
Step(MemberPair),
StepsOrder,
/// Definition AND default form one explicit constructor replacement group.
DataConstructor,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SemanticBaseline {
pub oa: OwnRecord,
pub sa: OwnRecord,
pub reviewed: Vec<Facet>,
pub orders: Vec<OrderCheckpoint>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OrderedCollection {
Steps,
}
/// Order overlaps membership in the live Vec, so it needs a separate review value.
/// No observation IDs, history versions, or generic value representation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OrderCheckpoint {
pub collection: OrderedCollection,
pub oa: Vec<Id>,
pub sa: Vec<Id>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Decision {
TakeSource,
KeepTarget,
Candidate,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Resolution {
pub facet: Facet,
pub decision: Decision,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
pub source: Id,
pub target: Id,
pub candidate: Option<OwnRecord>,
pub resolutions: Vec<Resolution>,
}
pub fn own(p: &Project, id: Id) -> OwnRecord {
if let Some(e) = p.elements.get(&id) {
OwnRecord::Element {
parent: e.parent,
body: e.body.clone(),
}
} else {
OwnRecord::Relation {
body: p.relations[&id].body.clone(),
}
}
}
fn shared(record: &OwnRecord) -> Result<&SharedBody, String> {
match record {
OwnRecord::Element { body, .. } => body.shared().ok_or("Not a shared body".into()),
_ => Err("Not an element".into()),
}
}
fn shared_mut(record: &mut OwnRecord) -> Result<&mut SharedBody, String> {
match record {
OwnRecord::Element { body, .. } => body.shared_mut().ok_or("Not a shared body".into()),
_ => Err("Not an element".into()),
}
}
fn list(record: &OwnRecord, trigger: bool) -> Result<&Vec<Id>, String> {
match (record, trigger) {
(
OwnRecord::Relation {
body: RelationBody::StateTransition { triggers, .. },
},
true,
) => Ok(triggers),
(OwnRecord::Element { body, .. }, false) => {
body.steps().ok_or("Not an ordered sequence".into())
}
_ => Err("Wrong member selector".into()),
}
}
fn list_mut(record: &mut OwnRecord, trigger: bool) -> Result<&mut Vec<Id>, String> {
match (record, trigger) {
(
OwnRecord::Relation {
body: RelationBody::StateTransition { triggers, .. },
},
true,
) => Ok(triggers),
(OwnRecord::Element { body, .. }, false) => {
body.steps_mut().ok_or("Not an ordered sequence".into())
}
_ => Err("Wrong member selector".into()),
}
}
/// Copy same-coordinate facet; mapped reference proposals use copy_member below.
fn copy(dst: &mut OwnRecord, src: &OwnRecord, facet: &Facet, oa: bool) -> Result<(), String> {
match facet {
Facet::IntegerMinimum | Facet::IntegerMaximum => {
let SharedBody::DataType {
definition: DataTypeDefinition::Integer { minimum, maximum },
..
} = shared(src)?
else {
return Err("Explicit constructor decision required".into());
};
let (minimum, maximum) = (*minimum, *maximum);
let SharedBody::DataType {
definition:
DataTypeDefinition::Integer {
minimum: dst_min,
maximum: dst_max,
},
..
} = shared_mut(dst)?
else {
return Err("Explicit constructor decision required".into());
};
if *facet == Facet::IntegerMinimum {
*dst_min = minimum;
} else {
*dst_max = maximum;
}
}
Facet::DataUnit => {
let SharedBody::DataType { unit, .. } = shared(src)? else {
return Err("Wrong facet".into());
};
let unit = unit.clone();
let SharedBody::DataType { unit: dst_unit, .. } = shared_mut(dst)? else {
return Err("Wrong facet".into());
};
*dst_unit = unit;
}
Facet::FieldMinimum => {
let SharedBody::DataField { minimum, .. } = shared(src)? else {
return Err("Wrong facet".into());
};
let minimum = *minimum;
let SharedBody::DataField {
minimum: dst_min, ..
} = shared_mut(dst)?
else {
return Err("Wrong facet".into());
};
*dst_min = minimum;
}
Facet::StateGuard => {
let OwnRecord::Relation {
body: RelationBody::StateTransition { guard, .. },
} = src
else {
return Err("Wrong facet".into());
};
let OwnRecord::Relation {
body:
RelationBody::StateTransition {
guard: dst_guard, ..
},
} = dst
else {
return Err("Wrong facet".into());
};
*dst_guard = guard.clone();
}
Facet::Trigger(pair) | Facet::Step(pair) => {
let id = if oa { pair.oa } else { pair.sa };
copy_member(dst, src, matches!(facet, Facet::Trigger(_)), id, id)?;
}
Facet::StepsOrder => *list_mut(dst, false)? = list(src, false)?.clone(),
Facet::DataConstructor => {
let SharedBody::DataType {
definition,
default,
..
} = shared(src)?
else {
return Err("Wrong constructor scope".into());
};
let (definition, default) = (definition.clone(), default.clone());
let SharedBody::DataType {
definition: dst_definition,
default: dst_default,
..
} = shared_mut(dst)?
else {
return Err("Wrong constructor scope".into());
};
*dst_definition = definition;
*dst_default = default;
}
}
Ok(())
}
fn copy_member(
dst: &mut OwnRecord,
src: &OwnRecord,
trigger: bool,
src_id: Option<Id>,
dst_id: Option<Id>,
) -> Result<(), String> {
if src_id.is_none() && dst_id.is_none() {
return Err("Empty member identity".into());
}
let present =
src_id.is_some_and(|id| list(src, trigger).is_ok_and(|items| items.contains(&id)));
let Some(dst_id) = dst_id else {
return if present {
Err("Missing counterpart mapping".into())
} else {
Ok(())
};
};
let items = list_mut(dst, trigger)?;
if present && !items.contains(&dst_id) {
items.push(dst_id);
}
if !present {
items.retain(|id| *id != dst_id);
}
Ok(())
}
fn equal(a: &OwnRecord, b: &OwnRecord, facet: &Facet, oa: bool) -> Result<bool, String> {
let mut projected = a.clone();
copy(&mut projected, b, facet, oa)?;
Ok(projected == *a)
}
pub fn reviewed_changed(
base: &SemanticBaseline,
now: &OwnRecord,
facet: &Facet,
oa: bool,
) -> Result<Option<bool>, String> {
if !base.reviewed.contains(facet) {
return Ok(None);
}
if *facet == Facet::StepsOrder {
let checkpoint = base
.orders
.iter()
.find(|o| o.collection == OrderedCollection::Steps)
.ok_or("Missing order checkpoint")?;
return Ok(Some(
list(now, false)? != if oa { &checkpoint.oa } else { &checkpoint.sa },
));
}
equal(if oa { &base.oa } else { &base.sa }, now, facet, oa).map(|same| Some(!same))
}
/// One validated core candidate, then baseline assignment. No partial commit.
/// OA→SA only in this bounded experiment.
pub fn apply(
p: &Project,
base: &SemanticBaseline,
review: &Review,
) -> Result<(Project, SemanticBaseline), String> {
let source = own(p, review.source);
let current = own(p, review.target);
let mut target = current.clone();
let mut acknowledged = base.clone();
let mut seen = Vec::new();
if review
.resolutions
.iter()
.any(|r| r.facet == Facet::DataConstructor)
&& review
.resolutions
.iter()
.any(|r| matches!(r.facet, Facet::IntegerMinimum | Facet::IntegerMaximum))
{
return Err("Constructor and descendant facets overlap".into());
}
for resolution in &review.resolutions {
if seen.contains(&resolution.facet) {
return Err("Duplicate facet".into());
}
seen.push(resolution.facet.clone());
match resolution.decision {
Decision::KeepTarget => {}
Decision::TakeSource => match &resolution.facet {
Facet::Trigger(pair) | Facet::Step(pair) => copy_member(
&mut target,
&source,
matches!(resolution.facet, Facet::Trigger(_)),
pair.oa,
pair.sa,
)?,
Facet::StepsOrder => {
return Err("This probe requires an explicit final order".into());
}
facet => copy(&mut target, &source, facet, false)?,
},
Decision::Candidate => {
let candidate = review.candidate.as_ref().ok_or("Missing candidate")?;
if resolution.facet == Facet::StepsOrder {
let mut expected = list(&target, false)?.clone();
let mut proposed = list(candidate, false)?.clone();
expected.sort();
proposed.sort();
if expected != proposed || proposed.windows(2).any(|p| p[0] == p[1]) {
return Err(
"Final order must contain every surviving target member exactly once"
.into(),
);
}
}
copy(&mut target, candidate, &resolution.facet, false)?;
}
}
if resolution.facet == Facet::DataConstructor {
acknowledged.reviewed.retain(|f| {
!matches!(
f,
Facet::IntegerMinimum | Facet::IntegerMaximum | Facet::DataConstructor
)
});
}
if resolution.facet == Facet::StepsOrder {
acknowledged
.orders
.retain(|o| o.collection != OrderedCollection::Steps);
acknowledged.orders.push(OrderCheckpoint {
collection: OrderedCollection::Steps,
oa: list(&source, false)?.clone(),
sa: list(&target, false)?.clone(),
});
} else {
copy(&mut acknowledged.oa, &source, &resolution.facet, true)?;
copy(&mut acknowledged.sa, &target, &resolution.facet, false)?;
}
if !acknowledged.reviewed.contains(&resolution.facet) {
acknowledged.reviewed.push(resolution.facet.clone());
}
}
// Explicit candidate cannot smuggle changes to any unreviewed facet.
if let Some(candidate) = &review.candidate {
if *candidate != target {
return Err("Candidate changes an unselected facet".into());
}
}
let command = match target {
OwnRecord::Element { parent, body } => {
let mut e = p.elements[&review.target].clone();
e.parent = parent;
e.body = body;
Command::EditElement(review.target, e)
}
OwnRecord::Relation { body } => {
let mut r = p.relations[&review.target].clone();
r.body = body;
Command::EditRelation(review.target, r)
}
};
let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?;
Ok((prepared.project, acknowledged))
}
#[cfg(test)]
mod tests {
use super::*;
pub(crate) fn add(
p: &mut Project,
kind: Kind,
layer: Layer,
name: &str,
parent: Option<Id>,
) -> Id {
p.add_element(kind, layer, name, parent).unwrap()
}
pub(crate) fn set(p: &mut Project, id: Id, body: SharedBody) {
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = body;
}
pub(crate) fn valid(p: &Project) {
let errors: Vec<_> = validation::validate(p)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "{errors:?}");
}
fn baseline(p: &Project, oa: Id, sa: Id, reviewed: Vec<Facet>) -> SemanticBaseline {
let orders = if reviewed.contains(&Facet::StepsOrder) {
vec![OrderCheckpoint {
collection: OrderedCollection::Steps,
oa: list(&own(p, oa), false).unwrap().clone(),
sa: list(&own(p, sa), false).unwrap().clone(),
}]
} else {
vec![]
};
SemanticBaseline {
oa: own(p, oa),
sa: own(p, sa),
reviewed,
orders,
}
}
fn resolution(facet: Facet, decision: Decision) -> Resolution {
Resolution { facet, decision }
}
fn roundtrip(p: &Project, base: &SemanticBaseline) {
let json = serde_json::to_string(base).unwrap();
assert_eq!(
serde_json::from_str::<SemanticBaseline>(&json).unwrap(),
*base
);
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
assert_eq!(
serde_json::from_str::<Project>(&serde_json::to_string(p).unwrap()).unwrap(),
*p
);
}
fn evidence(name: &str, p: &Project, base: &SemanticBaseline) {
let folder =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/r3-probe/evidence");
std::fs::create_dir_all(&folder).unwrap();
std::fs::write(
folder.join(format!("{name}-baseline.json")),
serde_json::to_string_pretty(base).unwrap(),
)
.unwrap();
std::fs::write(
folder.join(format!("{name}-candidate.arcadia")),
language::format(p),
)
.unwrap();
}
pub(crate) fn integer(minimum: i64, maximum: i64, unit: &str) -> SharedBody {
SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(minimum),
maximum: Some(maximum),
},
default: None,
unit: unit.into(),
}
}
#[test]
fn bounded_data_partial_keep_take_and_constructor_atomicity() {
let mut p = Project::empty();
let oa = add(&mut p, Kind::DataType, Layer::Oa, "OA bounded", None);
let sa = add(&mut p, Kind::DataType, Layer::Sa, "SA bounded", None);
set(&mut p, oa, integer(0, 100, "m"));
set(&mut p, sa, integer(0, 100, "ft"));
let base = baseline(
&p,
oa,
sa,
vec![
Facet::IntegerMinimum,
Facet::IntegerMaximum,
Facet::DataUnit,
],
);
set(&mut p, oa, integer(120, 200, "cm"));
let review = Review {
source: oa,
target: sa,
candidate: None,
resolutions: vec![
resolution(Facet::IntegerMinimum, Decision::KeepTarget),
resolution(Facet::DataUnit, Decision::KeepTarget),
],
};
let (kept, mixed) = apply(&p, &base, &review).unwrap();
assert_eq!(kept, p);
// OA baseline now min=120,max=100. This is intentional: fields were reviewed independently.
assert!(matches!(
shared(&mixed.oa).unwrap(),
SharedBody::DataType {
definition: DataTypeDefinition::Integer {
minimum: Some(120),
maximum: Some(100)
},
..
}
));
assert_eq!(
reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMinimum, true).unwrap(),
Some(false)
);
assert_eq!(
reviewed_changed(&mixed, &own(&p, oa), &Facet::IntegerMaximum, true).unwrap(),
Some(true)
);
let take = Review {
source: oa,
target: sa,
candidate: None,
resolutions: vec![resolution(Facet::IntegerMaximum, Decision::TakeSource)],
};
let (p, merged) = apply(&p, &mixed, &take).unwrap();
assert!(
matches!(shared(&own(&p, sa)).unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(200) }, unit, .. } if unit == "ft")
);
let (again, same) = apply(&p, &merged, &take).unwrap();
assert_eq!(again, p);
assert_eq!(same, merged);
roundtrip(&p, &mixed);
evidence("mixed-range", &p, &mixed);
let mut fields = p;
let owner_oa = add(&mut fields, Kind::DataType, Layer::Oa, "OA record", None);
let owner_sa = add(&mut fields, Kind::DataType, Layer::Sa, "SA record", None);
for id in [owner_oa, owner_sa] {
set(
&mut fields,
id,
SharedBody::DataType {
definition: DataTypeDefinition::Record { supertype: None },
default: None,
unit: String::new(),
},
);
}
let field_oa = add(
&mut fields,
Kind::DataField,
Layer::Oa,
"OA field",
Some(owner_oa),
);
let field_sa = add(
&mut fields,
Kind::DataField,
Layer::Sa,
"SA field",
Some(owner_sa),
);
for (id, max) in [(field_oa, 5), (field_sa, 9)] {
set(
&mut fields,
id,
SharedBody::DataField {
type_ref: TypeRef::Named(if id == field_oa { oa } else { sa }),
minimum: 0,
maximum: Some(max),
unit: String::new(),
direction: ParameterDirection::Unspecified,
storage: FieldStorage::Value,
default: None,
},
);
}
let field_base = baseline(&fields, field_oa, field_sa, vec![Facet::FieldMinimum]);
if let Some(SharedBody::DataField { minimum, .. }) =
fields.elements.get_mut(&field_oa).unwrap().shared_mut()
{
*minimum = 2;
}
let (fields, field_base) = apply(
&fields,
&field_base,
&Review {
source: field_oa,
target: field_sa,
candidate: None,
resolutions: vec![resolution(Facet::FieldMinimum, Decision::TakeSource)],
},
)
.unwrap();
assert!(matches!(
fields.elements[&field_sa].shared(),
Some(SharedBody::DataField {
minimum: 2,
maximum: Some(9),
..
})
));
roundtrip(&fields, &field_base);
let mut switched = fields.clone();
let constructor_base = baseline(&switched, owner_oa, owner_sa, vec![]);
// Remove only the source's child; the target-only field remains real independent work.
switched = prepare_command(&switched, &Command::Delete(field_oa))
.unwrap()
.project;
set(&mut switched, owner_oa, integer(0, 10, ""));
valid(&switched);
let before = switched.clone();
let before_base = constructor_base.clone();
let result = apply(
&switched,
&constructor_base,
&Review {
source: owner_oa,
target: owner_sa,
candidate: None,
resolutions: vec![resolution(Facet::DataConstructor, Decision::TakeSource)],
},
);
assert!(
result.is_err(),
"target Record child blocks primitive constructor switch"
);
assert_eq!(switched, before);
assert_eq!(constructor_base, before_base);
let forbidden = apply(
&switched,
&constructor_base,
&Review {
source: owner_oa,
target: owner_sa,
candidate: None,
resolutions: vec![resolution(Facet::IntegerMinimum, Decision::TakeSource)],
},
);
assert!(forbidden.unwrap_err().contains("constructor"));
println!(
"bounded data: mixed historical min/max permitted; unselected drift retained; target max/unit preserved; constructor candidate rejected atomically"
);
}
pub(crate) fn state_side(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) {
let owner = add(
p,
if layer == Layer::Oa {
Kind::Entity
} else {
Kind::System
},
layer,
&format!("{prefix} owner"),
None,
);
let machine = add(
p,
Kind::StateMachine,
layer,
&format!("{prefix} machine"),
Some(owner),
);
let region = add(
p,
Kind::Region,
layer,
&format!("{prefix} region"),
Some(machine),
);
let a = add(p, Kind::State, layer, &format!("{prefix} A"), Some(region));
let b = add(p, Kind::State, layer, &format!("{prefix} B"), Some(region));
let old = add(p, Kind::Event, layer, &format!("{prefix} old event"), None);
let new = add(p, Kind::Event, layer, &format!("{prefix} new event"), None);
let relation = p.relate(
RelationKind::StateTransition,
&format!("{prefix} transition"),
a,
b,
);
if let RelationBody::StateTransition {
triggers, guard, ..
} = &mut p.relations.get_mut(&relation).unwrap().body
{
*triggers = vec![old];
*guard = "ready".into();
}
(relation, old, new)
}
#[test]
fn state_guard_and_trigger_delta_retain_deleted_historical_identity() {
let mut p = Project::empty();
let (oa, old_oa, new_oa) = state_side(&mut p, Layer::Oa, "OA");
let (sa, old_sa, new_sa) = state_side(&mut p, Layer::Sa, "SA");
let extra = add(&mut p, Kind::Event, Layer::Sa, "Target only", None);
let old = MemberPair {
oa: Some(old_oa),
sa: Some(old_sa),
};
let new = MemberPair {
oa: Some(new_oa),
sa: Some(new_sa),
};
let base = baseline(
&p,
oa,
sa,
vec![Facet::StateGuard, Facet::Trigger(old), Facet::Trigger(new)],
);
p = prepare_command(&p, &Command::Delete(old_oa))
.unwrap()
.project;
if let RelationBody::StateTransition {
triggers, guard, ..
} = &mut p.relations.get_mut(&oa).unwrap().body
{
*triggers = vec![new_oa];
*guard = "armed".into();
}
if let RelationBody::StateTransition {
triggers, guard, ..
} = &mut p.relations.get_mut(&sa).unwrap().body
{
triggers.push(extra);
*guard = "local inhibit clear".into();
}
valid(&p);
assert!(!p.elements.contains_key(&old_oa));
assert!(list(&base.oa, true).unwrap().contains(&old_oa));
// Current source uses live @alias semantics. A historical body needs a
// distinct source context, not transplantation into a live declaration.
let mut historical_as_live = p.clone();
if let OwnRecord::Relation { body } = &base.oa {
historical_as_live.relations.get_mut(&oa).unwrap().body = body.clone();
}
let rejected = language::parse(&language::format(&historical_as_live));
assert!(
rejected.is_err(),
"deleted historical UUID is not a live alias"
);
let review = Review {
source: oa,
target: sa,
candidate: None,
resolutions: vec![
resolution(Facet::StateGuard, Decision::KeepTarget),
resolution(Facet::Trigger(old), Decision::TakeSource),
resolution(Facet::Trigger(new), Decision::TakeSource),
],
};
let (p, acknowledged) = apply(&p, &base, &review).unwrap();
assert_eq!(list(&own(&p, sa), true).unwrap(), &vec![extra, new_sa]);
assert!(p.elements.contains_key(&old_sa));
assert!(
matches!(&p.relations[&sa].body, RelationBody::StateTransition { guard, .. } if guard == "local inhibit clear")
);
roundtrip(&p, &base);
roundtrip(&p, &acknowledged);
evidence("deleted-trigger", &p, &base);
let (again, same) = apply(&p, &acknowledged, &review).unwrap();
assert_eq!(again, p);
assert_eq!(same, acknowledged);
println!(
"state: old source Event deleted; historical member pair still removes its target link, preserves target object and extra trigger, keeps guard"
);
}
pub(crate) fn sequence(p: &mut Project, layer: Layer, prefix: &str) -> (Id, Id, Id) {
let sequence = add(
p,
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
prefix,
None,
);
let a = add(
p,
Kind::Occurrence,
layer,
&format!("{prefix} A"),
Some(sequence),
);
let b = add(
p,
Kind::Occurrence,
layer,
&format!("{prefix} B"),
Some(sequence),
);
for id in [a, b] {
set(
p,
id,
SharedBody::Occurrence {
body: OccurrenceBody::Control {
kind: ControlKind::Choice,
},
precondition: String::new(),
postcondition: String::new(),
},
);
}
*p.elements.get_mut(&sequence).unwrap().steps_mut().unwrap() = vec![a, b];
(sequence, a, b)
}
#[test]
fn ordered_steps_preserve_target_only_members_and_reject_hidden_edits() {
let mut p = Project::empty();
let (oa, oa_a, oa_b) = sequence(&mut p, Layer::Oa, "OA flow");
let (sa, sa_a, sa_b) = sequence(&mut p, Layer::Sa, "SA flow");
let base = baseline(&p, oa, sa, vec![Facet::StepsOrder]);
// Counterexample: a bare carrier loses pending placement review after
// independently acknowledging append membership. The order checkpoint
// keeps the exact older order and therefore retains the drift.
let oa_c = Id::new_v4();
let sa_c = Id::new_v4();
let mut source_with_append = own(&p, oa);
list_mut(&mut source_with_append, false).unwrap().push(oa_c);
let mut membership_only = base.clone();
copy(
&mut membership_only.oa,
&source_with_append,
&Facet::Step(MemberPair {
oa: Some(oa_c),
sa: Some(sa_c),
}),
true,
)
.unwrap();
assert_eq!(
membership_only.oa, source_with_append,
"bare carrier would falsely report no order drift"
);
assert_eq!(
reviewed_changed(
&membership_only,
&source_with_append,
&Facet::StepsOrder,
true
)
.unwrap(),
Some(true)
);
let extra = add(
&mut p,
Kind::Occurrence,
Layer::Sa,
"Target only step",
Some(sa),
);
set(
&mut p,
extra,
SharedBody::Occurrence {
body: OccurrenceBody::Control {
kind: ControlKind::Merge,
},
precondition: String::new(),
postcondition: String::new(),
},
);
*p.elements.get_mut(&sa).unwrap().steps_mut().unwrap() = vec![sa_a, extra, sa_b];
*p.elements.get_mut(&oa).unwrap().steps_mut().unwrap() = vec![oa_b, oa_a];
valid(&p);
let take = Review {
source: oa,
target: sa,
candidate: None,
resolutions: vec![resolution(Facet::StepsOrder, Decision::TakeSource)],
};
assert!(
apply(&p, &base, &take)
.unwrap_err()
.contains("explicit final order")
);
let mut candidate = own(&p, sa);
*list_mut(&mut candidate, false).unwrap() = vec![sa_b, sa_a];
let mut review = Review {
candidate: Some(candidate),
resolutions: vec![resolution(Facet::StepsOrder, Decision::Candidate)],
..take
};
assert!(
apply(&p, &base, &review)
.unwrap_err()
.contains("every surviving")
);
*list_mut(review.candidate.as_mut().unwrap(), false).unwrap() = vec![sa_b, extra, sa_a];
let target_only = p.elements[&extra].clone();
let (p, acknowledged) = apply(&p, &base, &review).unwrap();
assert_eq!(list(&own(&p, sa), false).unwrap(), &vec![sa_b, extra, sa_a]);
assert_eq!(p.elements[&extra], target_only);
roundtrip(&p, &acknowledged);
evidence("target-only-step", &p, &acknowledged);
let mut duplicate = review.clone();
duplicate.resolutions.push(duplicate.resolutions[0].clone());
assert!(
apply(&p, &acknowledged, &duplicate)
.unwrap_err()
.contains("Duplicate")
);
let mut hidden = review;
if let Some(OwnRecord::Element { parent, .. }) = &mut hidden.candidate {
*parent = Some(extra);
}
assert!(
apply(&p, &acknowledged, &hidden)
.unwrap_err()
.contains("unselected")
);
println!(
"steps: ambiguous source reorder demands explicit candidate; missing target-only member and unselected parent edits reject"
);
}
}
@@ -0,0 +1,909 @@
//! C: closed typed reviewed facts. Whole records are transient candidates only.
use crate::{Decision, OwnRecord, list, list_mut, own, shared, shared_mut};
use oa_core::*;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
// PUBLIC TYPES: persisted facts have no carrier, review mask or shadow order.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Observed<T> {
NotApplicable,
Value(T),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Reviewed<T> {
pub oa: Observed<T>,
pub sa: Observed<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyPair<K> {
pub oa: Option<K>,
pub sa: Option<K>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ValueKey {
pub path: Vec<Id>,
pub field: Id,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataConstructor {
Boolean,
Integer,
Real,
String,
Enumeration,
Record,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DataFacet {
Constructor,
IntegerMinimum,
IntegerMaximum,
RealMinimum,
RealMaximum,
MinimumLength,
MaximumLength,
Literals,
Supertype,
Default,
Unit,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum DataReview {
Constructor(Reviewed<DataConstructor>),
IntegerMinimum(Reviewed<Option<i64>>),
IntegerMaximum(Reviewed<Option<i64>>),
RealMinimum(Reviewed<Option<f64>>),
RealMaximum(Reviewed<Option<f64>>),
MinimumLength(Reviewed<u32>),
MaximumLength(Reviewed<Option<u32>>),
Literals(Reviewed<Vec<String>>),
Supertype(Reviewed<Option<Id>>),
Default(Reviewed<Option<DataValue>>),
Unit(Reviewed<String>),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Selector {
Data(DataFacet),
FieldMinimum,
StateGuard,
Trigger(KeyPair<Id>),
Step(KeyPair<Id>),
StepOrder,
Annotation(KeyPair<ValueKey>),
Binding(KeyPair<Id>),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FacetReview {
Data(DataReview),
FieldMinimum(Reviewed<u32>),
StateGuard(Reviewed<String>),
Trigger {
keys: KeyPair<Id>,
values: Reviewed<bool>,
},
Step {
keys: KeyPair<Id>,
values: Reviewed<bool>,
},
StepOrder(Reviewed<Vec<Id>>),
Annotation {
keys: KeyPair<ValueKey>,
values: Reviewed<Option<MessageValue>>,
},
Binding {
keys: KeyPair<Id>,
values: Reviewed<Option<LifelineBinding>>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Baseline {
pub facets: Vec<FacetReview>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Resolution {
pub facet: Selector,
pub decision: Decision,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Review {
pub source: Id,
pub target: Id,
pub candidate: Option<OwnRecord>,
pub resolutions: Vec<Resolution>,
pub references: BTreeMap<Id, Id>,
}
// ADAPTER: exhaustive typed capture, comparison, and application for this subset.
fn observed<T>(v: Option<T>) -> Observed<T> {
v.map_or(Observed::NotApplicable, Observed::Value)
}
fn pair<T>(a: &OwnRecord, b: &OwnRecord, f: impl Fn(&OwnRecord) -> Option<T>) -> Reviewed<T> {
Reviewed {
oa: observed(f(a)),
sa: observed(f(b)),
}
}
fn data(r: &OwnRecord) -> Option<(&DataTypeDefinition, &Option<DataValue>, &String)> {
match shared(r).ok()? {
SharedBody::DataType {
definition,
default,
unit,
} => Some((definition, default, unit)),
_ => None,
}
}
fn constructor(d: &DataTypeDefinition) -> DataConstructor {
match d {
DataTypeDefinition::Boolean => DataConstructor::Boolean,
DataTypeDefinition::Integer { .. } => DataConstructor::Integer,
DataTypeDefinition::Real { .. } => DataConstructor::Real,
DataTypeDefinition::String { .. } => DataConstructor::String,
DataTypeDefinition::Enumeration { .. } => DataConstructor::Enumeration,
DataTypeDefinition::Record { .. } => DataConstructor::Record,
}
}
fn annotation(r: &OwnRecord, key: &Option<ValueKey>) -> Option<Option<MessageValue>> {
let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
} = shared(r).ok()?
else {
return None;
};
Some(key.as_ref().and_then(|key| {
values
.iter()
.find(|v| v.path == key.path && v.field == key.field)
.cloned()
}))
}
fn binding(r: &OwnRecord, key: Option<Id>) -> Option<Option<LifelineBinding>> {
let SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
} = shared(r).ok()?
else {
return None;
};
Some(key.and_then(|key| bindings.iter().find(|v| v.referenced == key).cloned()))
}
impl DataReview {
fn facet(&self) -> DataFacet {
match self {
Self::Constructor(_) => DataFacet::Constructor,
Self::IntegerMinimum(_) => DataFacet::IntegerMinimum,
Self::IntegerMaximum(_) => DataFacet::IntegerMaximum,
Self::RealMinimum(_) => DataFacet::RealMinimum,
Self::RealMaximum(_) => DataFacet::RealMaximum,
Self::MinimumLength(_) => DataFacet::MinimumLength,
Self::MaximumLength(_) => DataFacet::MaximumLength,
Self::Literals(_) => DataFacet::Literals,
Self::Supertype(_) => DataFacet::Supertype,
Self::Default(_) => DataFacet::Default,
Self::Unit(_) => DataFacet::Unit,
}
}
}
impl FacetReview {
pub fn selector(&self) -> Selector {
match self {
Self::Data(v) => Selector::Data(v.facet()),
Self::FieldMinimum(_) => Selector::FieldMinimum,
Self::StateGuard(_) => Selector::StateGuard,
Self::Trigger { keys, .. } => Selector::Trigger(keys.clone()),
Self::Step { keys, .. } => Selector::Step(keys.clone()),
Self::StepOrder(_) => Selector::StepOrder,
Self::Annotation { keys, .. } => Selector::Annotation(keys.clone()),
Self::Binding { keys, .. } => Selector::Binding(keys.clone()),
}
}
}
pub fn capture(a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> FacetReview {
match selector {
Selector::Data(f) => FacetReview::Data(match f {
DataFacet::Constructor => {
DataReview::Constructor(pair(a, b, |r| Some(constructor(data(r)?.0))))
}
DataFacet::IntegerMinimum => DataReview::IntegerMinimum(pair(a, b, |r| {
if let DataTypeDefinition::Integer { minimum, .. } = data(r)?.0 {
Some(*minimum)
} else {
None
}
})),
DataFacet::IntegerMaximum => DataReview::IntegerMaximum(pair(a, b, |r| {
if let DataTypeDefinition::Integer { maximum, .. } = data(r)?.0 {
Some(*maximum)
} else {
None
}
})),
DataFacet::RealMinimum => DataReview::RealMinimum(pair(a, b, |r| {
if let DataTypeDefinition::Real { minimum, .. } = data(r)?.0 {
Some(*minimum)
} else {
None
}
})),
DataFacet::RealMaximum => DataReview::RealMaximum(pair(a, b, |r| {
if let DataTypeDefinition::Real { maximum, .. } = data(r)?.0 {
Some(*maximum)
} else {
None
}
})),
DataFacet::MinimumLength => DataReview::MinimumLength(pair(a, b, |r| {
if let DataTypeDefinition::String { minimum_length, .. } = data(r)?.0 {
Some(*minimum_length)
} else {
None
}
})),
DataFacet::MaximumLength => DataReview::MaximumLength(pair(a, b, |r| {
if let DataTypeDefinition::String { maximum_length, .. } = data(r)?.0 {
Some(*maximum_length)
} else {
None
}
})),
DataFacet::Literals => DataReview::Literals(pair(a, b, |r| {
if let DataTypeDefinition::Enumeration { literals } = data(r)?.0 {
Some(literals.clone())
} else {
None
}
})),
DataFacet::Supertype => DataReview::Supertype(pair(a, b, |r| {
if let DataTypeDefinition::Record { supertype } = data(r)?.0 {
Some(*supertype)
} else {
None
}
})),
DataFacet::Default => DataReview::Default(pair(a, b, |r| Some(data(r)?.1.clone()))),
DataFacet::Unit => DataReview::Unit(pair(a, b, |r| Some(data(r)?.2.clone()))),
}),
Selector::FieldMinimum => FacetReview::FieldMinimum(pair(a, b, |r| {
if let SharedBody::DataField { minimum, .. } = shared(r).ok()? {
Some(*minimum)
} else {
None
}
})),
Selector::StateGuard => FacetReview::StateGuard(pair(a, b, |r| {
if let OwnRecord::Relation {
body: RelationBody::StateTransition { guard, .. },
} = r
{
Some(guard.clone())
} else {
None
}
})),
Selector::Trigger(keys) | Selector::Step(keys) => {
let triggers = matches!(selector, Selector::Trigger(_));
let values = Reviewed {
oa: observed(
list(a, triggers)
.ok()
.map(|v| keys.oa.is_some_and(|id| v.contains(&id))),
),
sa: observed(
list(b, triggers)
.ok()
.map(|v| keys.sa.is_some_and(|id| v.contains(&id))),
),
};
if triggers {
FacetReview::Trigger {
keys: keys.clone(),
values,
}
} else {
FacetReview::Step {
keys: keys.clone(),
values,
}
}
}
Selector::StepOrder => FacetReview::StepOrder(pair(a, b, |r| list(r, false).ok().cloned())),
Selector::Annotation(keys) => FacetReview::Annotation {
keys: keys.clone(),
values: Reviewed {
oa: observed(annotation(a, &keys.oa)),
sa: observed(annotation(b, &keys.sa)),
},
},
Selector::Binding(keys) => FacetReview::Binding {
keys: keys.clone(),
values: Reviewed {
oa: observed(binding(a, keys.oa)),
sa: observed(binding(b, keys.sa)),
},
},
}
}
impl Baseline {
pub fn capture(
p: &Project,
source: Id,
target: Id,
selectors: &[Selector],
) -> Result<Self, String> {
unique(selectors)?;
let result = Self {
facets: selectors
.iter()
.map(|f| capture(&own(p, source), &own(p, target), f))
.collect(),
};
result.validate()?;
Ok(result)
}
pub fn changed(&self, a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> Option<bool> {
self.facets
.iter()
.find(|v| v.selector() == *selector)
.map(|v| *v != capture(a, b, selector))
}
fn upsert(&mut self, review: FacetReview) {
self.facets.retain(|v| v.selector() != review.selector());
self.facets.push(review);
}
fn validate(&self) -> Result<(), String> {
unique(
&self
.facets
.iter()
.map(FacetReview::selector)
.collect::<Vec<_>>(),
)?;
for fact in &self.facets {
if !has_applicable_value(fact) {
return Err("Facet is inapplicable on both sides".into());
}
match fact {
FacetReview::Annotation { keys, values } => {
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty keyed facet".into());
}
for (key, value) in [(&keys.oa, &values.oa), (&keys.sa, &values.sa)] {
if let Observed::Value(Some(value)) = value
&& key.as_ref()
!= Some(&ValueKey {
path: value.path.clone(),
field: value.field,
})
{
return Err("Historical annotation payload disagrees with key".into());
}
}
}
FacetReview::Binding { keys, values } => {
if keys.oa.is_none() && keys.sa.is_none() {
return Err("Empty keyed facet".into());
}
for (key, value) in [(&keys.oa, &values.oa), (&keys.sa, &values.sa)] {
if let Observed::Value(Some(value)) = value
&& *key != Some(value.referenced)
{
return Err("Historical binding payload disagrees with key".into());
}
}
}
FacetReview::Trigger { keys, .. } | FacetReview::Step { keys, .. }
if keys.oa.is_none() && keys.sa.is_none() =>
{
return Err("Empty keyed facet".into());
}
FacetReview::Data(DataReview::RealMinimum(v) | DataReview::RealMaximum(v)) => {
for v in [&v.oa, &v.sa] {
if let Observed::Value(Some(v)) = v
&& !v.is_finite()
{
return Err("Historical real must be finite".into());
}
}
}
FacetReview::Data(DataReview::Default(v)) => {
for v in [&v.oa, &v.sa] {
if let Observed::Value(Some(DataValue::Real(v))) = v
&& !v.is_finite()
{
return Err("Historical real default must be finite".into());
}
}
}
_ => {}
}
}
Ok(())
}
}
fn has_applicable_value(fact: &FacetReview) -> bool {
fn any<T>(v: &Reviewed<T>) -> bool {
matches!(v.oa, Observed::Value(_)) || matches!(v.sa, Observed::Value(_))
}
match fact {
FacetReview::Data(v) => match v {
DataReview::Constructor(v) => any(v),
DataReview::IntegerMinimum(v) | DataReview::IntegerMaximum(v) => any(v),
DataReview::RealMinimum(v) | DataReview::RealMaximum(v) => any(v),
DataReview::MinimumLength(v) => any(v),
DataReview::MaximumLength(v) => any(v),
DataReview::Literals(v) => any(v),
DataReview::Supertype(v) => any(v),
DataReview::Default(v) => any(v),
DataReview::Unit(v) => any(v),
},
FacetReview::FieldMinimum(v) => any(v),
FacetReview::StateGuard(v) => any(v),
FacetReview::Trigger { values, .. } | FacetReview::Step { values, .. } => any(values),
FacetReview::StepOrder(v) => any(v),
FacetReview::Annotation { values, .. } => any(values),
FacetReview::Binding { values, .. } => any(values),
}
}
fn unique(selectors: &[Selector]) -> Result<(), String> {
fn overlap<K: PartialEq>(a: &KeyPair<K>, b: &KeyPair<K>) -> bool {
a.oa.as_ref().is_some_and(|k| b.oa.as_ref() == Some(k))
|| a.sa.as_ref().is_some_and(|k| b.sa.as_ref() == Some(k))
}
for (index, selector) in selectors.iter().enumerate() {
if selectors[..index].iter().any(|old| {
old == selector
|| match (old, selector) {
(Selector::Trigger(a), Selector::Trigger(b))
| (Selector::Step(a), Selector::Step(b))
| (Selector::Binding(a), Selector::Binding(b)) => overlap(a, b),
(Selector::Annotation(a), Selector::Annotation(b)) => overlap(a, b),
_ => false,
}
}) {
return Err("Duplicate facet or conflicting keyed slot".into());
}
}
Ok(())
}
fn applicable(a: &OwnRecord, b: &OwnRecord, selector: &Selector) -> bool {
[a, b].into_iter().all(|r| match selector {
Selector::Data(_) => data(r).is_some(),
Selector::FieldMinimum => matches!(shared(r), Ok(SharedBody::DataField { .. })),
Selector::StateGuard | Selector::Trigger(_) => matches!(
r,
OwnRecord::Relation {
body: RelationBody::StateTransition { .. }
}
),
Selector::Step(_) | Selector::StepOrder => list(r, false).is_ok(),
Selector::Annotation(_) => annotation(r, &None).is_some(),
Selector::Binding(_) => binding(r, None).is_some(),
})
}
fn check_keys(selector: &Selector, refs: &BTreeMap<Id, Id>) -> Result<(), String> {
fn ids(keys: &KeyPair<Id>, refs: &BTreeMap<Id, Id>) -> Result<(), String> {
match (keys.oa, keys.sa) {
(None, None) => Err("Empty keyed facet".into()),
(Some(a), Some(b)) if refs.get(&a) != Some(&b) => {
Err("Key pair disagrees with frozen reference mapping".into())
}
_ => Ok(()),
}
}
match selector {
Selector::Trigger(k) | Selector::Step(k) | Selector::Binding(k) => ids(k, refs),
Selector::Annotation(k) => match (&k.oa, &k.sa) {
(None, None) => Err("Empty keyed facet".into()),
(Some(a), Some(b)) => {
if a.path.len() != b.path.len() {
return Err("Annotation path mapping differs".into());
}
for (oa, sa) in a
.path
.iter()
.chain([&a.field])
.zip(b.path.iter().chain([&b.field]))
{
ids(
&KeyPair {
oa: Some(*oa),
sa: Some(*sa),
},
refs,
)?;
}
Ok(())
}
_ => Ok(()),
},
_ => Ok(()),
}
}
fn chosen<T: Clone>(v: &Reviewed<T>, source: bool) -> Result<T, String> {
match if source { &v.oa } else { &v.sa } {
Observed::Value(v) => Ok(v.clone()),
Observed::NotApplicable => {
Err("Facet is not applicable; explicit constructor review required".into())
}
}
}
fn definition_mut(r: &mut OwnRecord) -> Result<&mut DataTypeDefinition, String> {
match shared_mut(r)? {
SharedBody::DataType { definition, .. } => Ok(definition),
_ => Err("Wrong data constructor scope".into()),
}
}
fn map_ref(id: Id, refs: &BTreeMap<Id, Id>) -> Result<Id, String> {
refs.get(&id)
.copied()
.ok_or_else(|| format!("Missing reference mapping for {id}"))
}
fn write(
target: &mut OwnRecord,
fact: &FacetReview,
source: bool,
refs: &BTreeMap<Id, Id>,
) -> Result<(), String> {
macro_rules! bound {
($v:expr, $variant:ident, $field:ident) => {{
let value = chosen($v, source)?;
let DataTypeDefinition::$variant { $field, .. } = definition_mut(target)? else {
return Err("Explicit constructor review required".into());
};
*$field = value;
}};
}
match fact {
FacetReview::Data(v) => match v {
DataReview::IntegerMinimum(v) => bound!(v, Integer, minimum),
DataReview::IntegerMaximum(v) => bound!(v, Integer, maximum),
DataReview::RealMinimum(v) => bound!(v, Real, minimum),
DataReview::RealMaximum(v) => bound!(v, Real, maximum),
DataReview::MinimumLength(v) => bound!(v, String, minimum_length),
DataReview::MaximumLength(v) => bound!(v, String, maximum_length),
DataReview::Literals(v) => bound!(v, Enumeration, literals),
DataReview::Supertype(v) => {
let mut value = chosen(v, source)?;
if source {
value = value.map(|id| map_ref(id, refs)).transpose()?;
}
let DataTypeDefinition::Record { supertype } = definition_mut(target)? else {
return Err("Explicit constructor review required".into());
};
*supertype = value;
}
DataReview::Default(v) => {
let SharedBody::DataType { default, .. } = shared_mut(target)? else {
return Err("Wrong facet".into());
};
*default = chosen(v, source)?;
}
DataReview::Unit(v) => {
let SharedBody::DataType { unit, .. } = shared_mut(target)? else {
return Err("Wrong facet".into());
};
*unit = chosen(v, source)?;
}
DataReview::Constructor(_) => return Err("Constructor is a grouped operation".into()),
},
FacetReview::FieldMinimum(v) => {
let SharedBody::DataField { minimum, .. } = shared_mut(target)? else {
return Err("Wrong facet".into());
};
*minimum = chosen(v, source)?;
}
FacetReview::StateGuard(v) => {
let OwnRecord::Relation {
body: RelationBody::StateTransition { guard, .. },
} = target
else {
return Err("Wrong facet".into());
};
*guard = chosen(v, source)?;
}
FacetReview::Trigger { keys, values } | FacetReview::Step { keys, values } => {
let present = chosen(values, source)?;
let Some(id) = keys.sa else {
return if present {
Err("Missing member mapping".into())
} else {
Ok(())
};
};
let items = list_mut(target, matches!(fact, FacetReview::Trigger { .. }))?;
if present && !items.contains(&id) {
items.push(id);
} else if !present {
items.retain(|v| *v != id);
}
}
FacetReview::StepOrder(values) => {
if source {
return Err("Explicit final order required by this probe".into());
}
let value = chosen(values, false)?;
let mut expected = list(target, false)?.clone();
let mut proposed = value.clone();
expected.sort();
proposed.sort();
if expected != proposed || proposed.windows(2).any(|p| p[0] == p[1]) {
return Err("Final order must contain every surviving member exactly once".into());
}
*list_mut(target, false)? = value;
}
FacetReview::Annotation { keys, values } => {
let mut value = chosen(values, source)?;
let Some(key) = &keys.sa else {
return if value.is_some() {
Err("Missing annotation mapping".into())
} else {
Ok(())
};
};
if let Some(value) = &mut value {
value.path = key.path.clone();
value.field = key.field;
}
let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
} = shared_mut(target)?
else {
return Err("Wrong annotation scope".into());
};
if let Some(index) = values
.iter()
.position(|v| v.path == key.path && v.field == key.field)
{
if let Some(value) = value {
values[index] = value;
} else {
values.remove(index);
}
} else if let Some(value) = value {
values.push(value);
}
}
FacetReview::Binding { keys, values } => {
let mut value = chosen(values, source)?;
let Some(key) = keys.sa else {
return if value.is_some() {
Err("Missing binding mapping".into())
} else {
Ok(())
};
};
if let Some(value) = &mut value {
value.referenced = key;
if source {
value.local = map_ref(value.local, refs)?;
}
}
let SharedBody::Occurrence {
body: OccurrenceBody::ScenarioUse { bindings, .. },
..
} = shared_mut(target)?
else {
return Err("Wrong binding scope".into());
};
if let Some(index) = bindings.iter().position(|v| v.referenced == key) {
if let Some(value) = value {
bindings[index] = value;
} else {
bindings.remove(index);
}
} else if let Some(value) = value {
bindings.push(value);
}
}
}
Ok(())
}
fn in_constructor(selector: &Selector) -> bool {
matches!(selector, Selector::Data(f) if *f != DataFacet::Unit)
}
fn constructor_group(a: &OwnRecord, b: &OwnRecord) -> Vec<FacetReview> {
use DataFacet::*;
let mut facets = vec![Constructor, Default];
for r in [a, b] {
if let Some((definition, ..)) = data(r) {
for f in match definition {
DataTypeDefinition::Integer { .. } => vec![IntegerMinimum, IntegerMaximum],
DataTypeDefinition::Real { .. } => vec![RealMinimum, RealMaximum],
DataTypeDefinition::String { .. } => vec![MinimumLength, MaximumLength],
DataTypeDefinition::Enumeration { .. } => vec![Literals],
DataTypeDefinition::Record { .. } => vec![Supertype],
DataTypeDefinition::Boolean => vec![],
} {
if !facets.contains(&f) {
facets.push(f);
}
}
}
}
facets
.into_iter()
.map(|f| capture(a, b, &Selector::Data(f)))
.collect()
}
pub fn apply(p: &Project, base: &Baseline, review: &Review) -> Result<(Project, Baseline), String> {
base.validate()?;
unique(
&review
.resolutions
.iter()
.map(|r| r.facet.clone())
.collect::<Vec<_>>(),
)?;
let switch = review
.resolutions
.iter()
.any(|r| r.facet == Selector::Data(DataFacet::Constructor));
if switch
&& review
.resolutions
.iter()
.any(|r| in_constructor(&r.facet) && r.facet != Selector::Data(DataFacet::Constructor))
{
return Err("Overlapping constructor and descendant decisions".into());
}
let source = own(p, review.source);
let current = own(p, review.target);
let mut target = current.clone();
let constructor_selector = Selector::Data(DataFacet::Constructor);
for r in &review.resolutions {
if !applicable(&source, &current, &r.facet) {
return Err("Facet does not belong to this record family".into());
}
check_keys(&r.facet, &review.references)?;
if !has_applicable_value(&capture(&source, &current, &r.facet)) {
return Err("Facet is inapplicable on both sides".into());
}
for previous in &base.facets {
if previous.selector() != r.facet
&& unique(&[previous.selector(), r.facet.clone()]).is_err()
{
return Err(
"Mapping changed; explicitly invalidate the affected previous facet review"
.into(),
);
}
}
if r.facet == Selector::Data(DataFacet::Literals) && r.decision == Decision::TakeSource {
return Err("String-keyed literal deltas are outside this probe; no whole-list TakeSource overwrite".into());
}
}
if switch && base.changed(&source, &current, &constructor_selector) == Some(false) {
let decision = review
.resolutions
.iter()
.find(|r| r.facet == constructor_selector)
.unwrap()
.decision;
let proposed = match decision {
Decision::TakeSource => Some(&source),
Decision::Candidate => review.candidate.as_ref(),
Decision::KeepTarget => None,
};
let actual_target_switch = proposed
.and_then(data)
.zip(data(&current))
.is_some_and(|(new, old)| constructor(new.0) != constructor(old.0));
if !actual_target_switch {
return Err("No constructor change; review descendant facets individually".into());
}
}
if !switch
&& base.changed(&source, &current, &constructor_selector) == Some(true)
&& review.resolutions.iter().any(|r| in_constructor(&r.facet))
{
return Err("Explicit constructor review required".into());
}
let mut resolutions = review.resolutions.iter().collect::<Vec<_>>();
resolutions.sort_by_key(|r| {
if r.facet == constructor_selector {
0
} else if r.facet == Selector::StepOrder {
2
} else {
1
}
});
for r in &resolutions {
if r.decision == Decision::KeepTarget {
continue;
}
let from_source = r.decision == Decision::TakeSource;
let supplied = if from_source {
&source
} else {
review.candidate.as_ref().ok_or("Missing candidate")?
};
if r.facet == constructor_selector {
let (definition, default, _) = data(supplied).ok_or("Wrong constructor group")?;
let mut definition = definition.clone();
if from_source
&& let DataTypeDefinition::Enumeration { literals } = &definition
&& let Some((DataTypeDefinition::Enumeration { literals: old }, ..)) = data(&target)
&& old.iter().any(|v| !literals.contains(v))
{
return Err(
"Constructor candidate must explicitly review target-only enum literals".into(),
);
}
if from_source
&& let DataTypeDefinition::Record {
supertype: Some(id),
} = &mut definition
{
*id = map_ref(*id, &review.references)?;
}
let SharedBody::DataType {
definition: to_definition,
default: to_default,
..
} = shared_mut(&mut target)?
else {
return Err("Wrong constructor group".into());
};
*to_definition = definition;
*to_default = default.clone();
} else {
let fact = capture(&source, supplied, &r.facet);
write(&mut target, &fact, from_source, &review.references)?;
}
}
if review.candidate.as_ref().is_some_and(|v| *v != target) {
return Err("Candidate changes an unselected facet".into());
}
let mut expected = p.clone();
let command = match target {
OwnRecord::Element { parent, body } => {
let e = expected.elements.get_mut(&review.target).unwrap();
e.parent = parent;
e.body = body;
Command::EditElement(review.target, e.clone())
}
OwnRecord::Relation { body } => {
let r = expected.relations.get_mut(&review.target).unwrap();
r.body = body;
Command::EditRelation(review.target, r.clone())
}
};
let prepared = prepare_command(p, &Command::Batch(vec![command])).map_err(|e| e.to_string())?;
if prepared.project != expected {
return Err("Prepared candidate induces unreviewed changes outside selected facets".into());
}
let target = own(&prepared.project, review.target);
let mut acknowledged = base.clone();
for r in resolutions {
if r.facet == constructor_selector {
acknowledged
.facets
.retain(|f| !in_constructor(&f.selector()));
for f in constructor_group(&source, &target) {
acknowledged.upsert(f);
}
} else {
acknowledged.upsert(capture(&source, &target, &r.facet));
}
}
acknowledged.validate()?;
Ok((prepared.project, acknowledged))
}
#[cfg(test)]
#[path = "typed_reviews_tests.rs"]
mod tests;
File diff suppressed because it is too large Load Diff
-6
View File
@@ -63,9 +63,3 @@ 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 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.
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.
## Existing architecture and retained decisions
+36 -97
View File
@@ -2,53 +2,17 @@
## 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:
attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
attachment07aee762-7d27-4cbe-81c3-2e82c7170711/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
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.
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.
Meaningful implementation and runtime progress continues; no external blocker exists.
## Accepted foundations
@@ -112,57 +76,44 @@ 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.
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.
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.
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.
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.
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 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.
Explicit debug inheritance timings250/500/1000:147/664/2739ms. Optimized timing is
still required; no optimized performance claim yet.
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.
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.
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
scenario-value authoring, then full integrated Windows/Linux verification.
the same evidence in c5276b1. Finish the3 concrete desktop review fixes and
optimized inheritance measurement, then native data-authoring acceptance.
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. Use the experimentally selected typed facet review design C; the semantic
integration checkout records the full scope and source5 migration plan.
baselines/reconciliation only cover prose. Prefer historical typed-body snapshots
and finite proposals; avoid a duplicate recursive semantic schema without evidence.
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
@@ -170,13 +121,12 @@ plan and decision are recorded in that checkout. No overlapping writers or targe
## Environment and native ownership
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.
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.
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
@@ -187,14 +137,3 @@ 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.
+3 -13
View File
@@ -1,6 +1,6 @@
# Task Ledger — first release
Authoritative objective: attachment10134df9-bea2-4c02-a820-67b53d6b86f7/
Authoritative objective: attachment07aee762-7d27-4cbe-81c3-2e82c7170711/
goal-objective.md, read fully2026-09-06. STATE.md records execution; PLAN.md retains
scope; DECISIONS.md and linked contracts preserve rationale.
@@ -39,13 +39,6 @@ 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
@@ -55,11 +48,8 @@ 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 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.
- R3 typed historical bodies plus finite proposals appear simpler than a separate
recursive semantic-value schema. This candidate awaits R6 freeze and review.
## Decisions and current strategy
-119
View File
@@ -1,119 +0,0 @@
# 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
@@ -1,186 +0,0 @@
# 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
@@ -1,236 +0,0 @@
# 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.