From 321897fd55af9e07afa2c6ebf1e50be49e86f7d9 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Fri, 11 Sep 2026 10:38:05 -0700 Subject: [PATCH] Integrate contextual source creation with lifecycle rendering --- .gitattributes | 5 + crates/oa-desktop/src/app.rs | 81 +- crates/oa-desktop/src/app/checkpoints.rs | 5 + crates/oa-desktop/src/behavior.rs | 72 + .../src/behavior/contextual_source_tests.rs | 1328 +++++++++++++++++ crates/oa-desktop/src/behavior/creation.rs | 113 +- crates/oa-desktop/src/behavior/scenario.rs | 135 +- .../src/behavior/scenario/workspace.rs | 570 +++---- .../oa-desktop/src/behavior/source_draft.rs | 1034 +++++++++++++ crates/oa-desktop/src/behavior/topology.rs | 110 +- .../src/behavior/topology/contextual.rs | 2 +- .../src/behavior/topology/direct.rs | 10 +- .../oa-desktop/src/behavior/topology/view.rs | 409 ++--- crates/oa-desktop/src/guide.rs | 11 +- harness/STATE.md | 6 +- .../staged-source-authoring/README.md | 52 + .../base/notes.arcadia | 1 + .../base/project.arcadia | 15 + ...tual-integration-update-ytr83r-20260911.md | 121 ++ ...elease-lifecycle-native-ytr83r-20260911.md | 6 +- harness/staged-source-authoring-contract.rs | 635 ++++++++ 21 files changed, 4250 insertions(+), 471 deletions(-) create mode 100644 crates/oa-desktop/src/behavior/contextual_source_tests.rs create mode 100644 crates/oa-desktop/src/behavior/source_draft.rs create mode 100644 harness/fixtures/staged-source-authoring/README.md create mode 100644 harness/fixtures/staged-source-authoring/base/notes.arcadia create mode 100644 harness/fixtures/staged-source-authoring/base/project.arcadia create mode 100644 harness/release-contextual-integration-update-ytr83r-20260911.md create mode 100644 harness/staged-source-authoring-contract.rs diff --git a/.gitattributes b/.gitattributes index 6c206380..28ce18d0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -50,3 +50,8 @@ scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace + +harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol +harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol + +harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index e908fe88..8859132a 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -854,8 +854,12 @@ impl App { let scenario_selection = self .behavior .scenario - .created_selection(&command) - .or_else(|| self.behavior.topology.created_selection(&command)); + .created_selection(self.project(), &command) + .or_else(|| { + self.behavior + .topology + .created_selection(self.project(), &command) + }); match self.document.execute(command) { Ok(selected) => { if let Some(created) = created { @@ -1031,8 +1035,10 @@ impl App { window::close_requests().map(|id| Message::Request(Pending::Close(id))), window::resize_events().map(|(_, size)| Message::WindowResized(size)), architecture_authoring::subscription(self), - behavior::creation::subscription(self.behavior.creation.has_pending()) - .map(|()| Message::Escape), + behavior::creation::subscription( + self.behavior.creation.has_pending() || self.behavior.source_active(), + ) + .map(|()| Message::Escape), iced::event::listen_with(|event, status, _| { let iced::Event::Keyboard(keyboard::Event::KeyPressed { key, @@ -1519,6 +1525,37 @@ impl App { return Task::none(); } match mutation { + CanvasEvent::First(_, tool) => { + let empty = match tool { + scenario::Tool::Lifeline => !self + .project() + .children(context.scenario) + .any(|e| e.kind() == Kind::Lifeline), + scenario::Tool::Message | scenario::Tool::Action => { + self.project() + .children(context.scenario) + .any(|e| e.kind() == Kind::Lifeline) + && !self + .project() + .children(context.scenario) + .any(|e| e.kind() == Kind::Occurrence) + } + _ => false, + }; + if !empty { + self.status="This scenario already has content. Use its current authoring tools.".into(); + return Task::none(); + } + let mut state = std::mem::take(&mut self.behavior.scenario); + let result = state.update( + self.project(), + scenario::Event::Start(context.scenario, tool), + ); + self.behavior.scenario = state; + if let Err(error) = result { + self.status = error; + } + } CanvasEvent::BeginOccurrence { tool, slot, @@ -1754,7 +1791,15 @@ impl App { return self.update(Message::Add(child)); } Message::Escape => { - if self.behavior.creation.has_pending() { + if self.behavior.source_back() { + self.status = + "Returned to the unchanged parent form; no source was created.".into(); + return Task::none(); + } + if self.behavior.source_active() { + self.behavior.acknowledge_commit(); + self.status = "Behavior form cancelled; no staged source was created.".into(); + } else if self.behavior.creation.has_pending() { self.behavior.creation.acknowledge_commit(); self.status = "Behavior creation cancelled".into(); } @@ -2321,10 +2366,13 @@ impl App { self.status = "Apply or cancel the current draft before starting a new behavior. Your inputs are retained.".into(); return Task::none(); } - if !creating && event.starts_form() && !self.behavior.has_pending() && !self.apply() + if (event.starts_form() || (event.is_source() && !event.is_source_back())) + && (self.source_dirty() || self.draft_dirty()) { + self.status="Apply or cancel the other editors before starting this form. Your inputs are retained.".into(); return Task::none(); } + let source_was_open = self.behavior.source_open(); if event.is_submit() && (self.source_dirty() || self.draft_dirty()) { self.status = "Revert the other drafts before applying the behavior form. Your inputs are retained.".into(); return Task::none(); @@ -2342,6 +2390,11 @@ impl App { } } Ok(None) => { + if !source_was_open && self.behavior.source_open() { + return iced::widget::operation::focus( + behavior::source_draft::NAME_INPUT, + ); + } if focus_name { return iced::widget::operation::focus(behavior::creation::NAME_INPUT); } @@ -2537,13 +2590,19 @@ impl App { Message::Undo | Message::Redo => { let redo = matches!(message, Message::Redo); if self.apply() { - if redo { - self.document.redo(); + let changed = if redo { + self.document.redo() } else { - self.document.undo(); + self.document.undo() + }; + if changed { + self.sync(); } - self.sync(); - self.status = if redo { + self.status = if !changed && redo { + "Nothing to redo" + } else if !changed { + "Nothing to undo" + } else if redo { "Model change redone" } else { "Model change undone" diff --git a/crates/oa-desktop/src/app/checkpoints.rs b/crates/oa-desktop/src/app/checkpoints.rs index 258ef764..bf705335 100644 --- a/crates/oa-desktop/src/app/checkpoints.rs +++ b/crates/oa-desktop/src/app/checkpoints.rs @@ -214,6 +214,11 @@ impl App { .is_some_and(architecture_authoring::State::inline) { Task::batch([lint, iced::widget::operation::focus("architecture-name")]) + } else if self.page == Page::Behavior && self.behavior.source_open() { + Task::batch([ + lint, + iced::widget::operation::focus(behavior::source_draft::NAME_INPUT), + ]) } else if self.page == Page::Behavior && self.behavior.creation.created().is_some() { Task::batch([ lint, diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index b3078662..80aaa3f9 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -14,9 +14,12 @@ use iced::{ }; use oa_core::*; use std::collections::BTreeMap; +#[cfg(test)] +mod contextual_source_tests; pub(crate) mod creation; pub(crate) mod scenario; mod scenario_lifecycle; +pub(crate) mod source_draft; pub(crate) mod state_canvas; pub(crate) mod state_edit; pub(crate) mod state_menu; @@ -740,6 +743,29 @@ pub enum Event { Submit, } impl Event { + pub fn is_source_back(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) | Self::Scenario(scenario::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) | Self::Topology(topology::Event::Source(source_draft::Event::Edit( + _, + source_draft::Edit::Back + ))) + ) + } + pub fn is_source(&self) -> bool { + matches!( + self, + Self::Creation(creation::Event::Source(_)) + | Self::Scenario(scenario::Event::Source(_)) + | Self::Topology(topology::Event::Source(_)) + ) + } pub fn starts_form(&self) -> bool { matches!( self, @@ -870,6 +896,18 @@ impl State { /// Clear the active form after its prepared command commits successfully. /// Rejected commands must leave the form available for repair and retry. + pub fn source_active(&self) -> bool { + self.creation.source_active() + || self.scenario.source_active() + || self.topology.source_active() + } + pub fn source_open(&self) -> bool { + self.creation.source_open() || self.scenario.source_open() || self.topology.source_open() + } + pub fn source_back(&mut self) -> bool { + self.creation.source_back() || self.scenario.source_back() || self.topology.source_back() + } + pub fn acknowledge_commit(&mut self) { self.creation.acknowledge_commit(); self.topology.acknowledge_commit(); @@ -917,6 +955,40 @@ pub fn view(app: &App) -> Element<'_, Message> { if let Some(card) = app.behavior.creation.view(p) { return card.map(|event| Message::Behavior(Event::Creation(event))); } + if let Some(owner) = app.behavior.pending_owner() { + let kind = p.elements.get(&owner).map(oa_core::Element::kind); + let unavailable = p.layer_of(owner) != Some(app.layer) + || (app.behavior.scenario.pending_owner().is_some() && kind != Some(Kind::Scenario)) + || (app.behavior.topology.pending_owner().is_some() + && !matches!(kind, Some(Kind::Process | Kind::FunctionalChain))); + if unavailable { + let mut out=column![text("The pending behavior owner is unavailable").size(20),text("Your inputs are retained. Return from the source form or cancel this parent draft before choosing another behavior.").size(13)].spacing(12); + if let Some(name) = app + .behavior + .scenario + .pending_name() + .or_else(|| app.behavior.topology.pending_name()) + { + out = + out.push(text(format!("Pending consumer name: {}", short(name, 80))).size(14)); + } + if let Some(child) = app.behavior.scenario.source_view(p) { + out = out.push(child.map(|e| Message::Behavior(Event::Scenario(e)))); + } else if let Some(child) = app.behavior.topology.source_view(p) { + out = out.push(child.map(|e| Message::Behavior(Event::Topology(e)))); + } else { + let cancel = if app.behavior.scenario.pending_owner().is_some() { + Event::Scenario(scenario::Event::Cancel) + } else if app.behavior.topology.pending_owner().is_some() { + Event::Topology(topology::Event::Cancel) + } else { + Event::States(states::Event::Cancel) + }; + out = out.push(button("Cancel retained draft").on_press(Message::Behavior(cancel))); + } + return container(out).padding(16).width(Fill).height(Fill).into(); + } + } let choices = p .elements .values() diff --git a/crates/oa-desktop/src/behavior/contextual_source_tests.rs b/crates/oa-desktop/src/behavior/contextual_source_tests.rs new file mode 100644 index 00000000..8f4a51bb --- /dev/null +++ b/crates/oa-desktop/src/behavior/contextual_source_tests.rs @@ -0,0 +1,1328 @@ +use super::{creation, scenario, source_draft as source, topology}; +use crate::document::Document; +use oa_core::source_project::ProjectLimits; +use oa_core::*; + +fn id(value: &serde_json::Value, field: &str) -> Id { + serde_json::from_value(value[field].clone()).unwrap() +} +fn child(value: &serde_json::Value, edit: source::Edit) -> source::Event { + source::Event::Edit(id(&value["source"]["editing"], "token"), edit) +} +fn scenario_project(layer: Layer, viewpoint: ScenarioViewpoint) -> (Project, Id) { + let mut p = Project::empty(); + let owner = p + .add_element(Kind::Scenario, layer, "Emergency coordination", None) + .unwrap(); + if let Some(SharedBody::Scenario { viewpoint: v, .. }) = + p.elements.get_mut(&owner).unwrap().shared_mut() + { + *v = viewpoint; + } + (p, owner) +} +fn scenario_source(s: &mut scenario::State, p: &Project, name: &str, kind: Option) { + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + p, + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + if let Some(kind) = kind { + s.update( + p, + scenario::Event::Source(child(&raw, source::Edit::Kind(kind))), + ) + .unwrap(); + } + s.update( + p, + scenario::Event::Source(child(&raw, source::Edit::Name(name.into()))), + ) + .unwrap(); + s.update(p, scenario::Event::Source(child(&raw, source::Edit::Use))) + .unwrap(); +} +fn history(p: &Project, command: Command) -> Project { + let expected = execute_batch(p, std::slice::from_ref(&command)).unwrap(); + let mut doc = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); + let before = doc.bundle().clone(); + doc.execute(command).unwrap(); + assert_eq!(doc.project(), &expected); + let after = doc.bundle().clone(); + assert!(doc.undo()); + assert_eq!(doc.project(), p); + assert_eq!(doc.bundle(), &before); + assert!(!doc.can_undo()); + assert!(doc.redo()); + assert_eq!(doc.project(), &expected); + assert_eq!(doc.bundle(), &after); + assert_eq!( + Document::from_bundle(after, ProjectLimits::default()) + .unwrap() + .project(), + &expected + ); + expected +} + +#[test] +fn contextual_source_lifelines_stage_exact_separate_records_and_one_history_entry() { + for (layer, viewpoint, kind) in [ + ( + Layer::Oa, + ScenarioViewpoint::OperationalActivity, + Kind::Activity, + ), + (Layer::Sa, ScenarioViewpoint::Functional, Kind::Function), + ( + Layer::Oa, + ScenarioViewpoint::ParticipantInteraction, + Kind::Role, + ), + ( + Layer::Sa, + ScenarioViewpoint::BoundaryInterface, + Kind::SystemActor, + ), + ] { + let (p, owner) = scenario_project(layer, viewpoint); + let before = p.clone(); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + s.update(&p, scenario::Event::Name("Assessment role 雨".into())) + .unwrap(); + scenario_source(&mut s, &p, "Assess distress 雨", Some(kind)); + assert_eq!(p, before); + let raw = s.recovery_snapshot().unwrap().unwrap(); + let command = s.prepare_pending(&p).unwrap(); + assert_eq!(command, s.prepare_pending(&p).unwrap()); + let recovered = scenario::State::from_recovery(raw).unwrap(); + assert_eq!(command, recovered.prepare_pending(&p).unwrap()); + let selected = s.created_selection(&p, &command).unwrap(); + let result = history(&p, command); + assert_eq!(result.elements.len(), p.elements.len() + 2); + assert_eq!(result.relations, p.relations); + assert_eq!(result.diagrams, p.diagrams); + let line = &result.elements[&selected]; + assert_eq!(line.name, "Assessment role 雨"); + assert_eq!(line.parent, Some(owner)); + let Some(SharedBody::Lifeline { + represented: Some(represented), + }) = line.shared() + else { + panic!("lifeline") + }; + let work = &result.elements[represented]; + assert_eq!(work.name, "Assess distress 雨"); + assert_eq!(work.kind(), kind); + assert_eq!(work.parent, None); + } +} + +#[test] +fn contextual_source_default_consumer_name_has_stable_alias_and_correlated_selection() { + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + scenario_source(&mut s, &p, "Same source and consumer name", None); + let command = s.prepare_pending(&p).unwrap(); + assert_eq!(command, s.prepare_pending(&p).unwrap()); + let selected = s.created_selection(&p, &command).unwrap(); + let result = execute_batch(&p, std::slice::from_ref(&command)).unwrap(); + assert_eq!( + result.elements[&selected].name, + "Same source and consumer name" + ); + let unrelated = Command::Batch(vec![command, Command::Rename(selected, "Other".into())]); + assert!(s.created_selection(&p, &unrelated).is_none()); +} + +#[test] +fn contextual_source_back_retains_parent_and_rejects_late_child_event() { + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + s.update(&p, scenario::Event::Name(" Parent name 雨\n".into())) + .unwrap(); + let before = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + scenario::Event::Source(source::Event::Open(id(&before, "invocation"))), + ) + .unwrap(); + let open = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + scenario::Event::Source(child(&open, source::Edit::Name("discarded".into()))), + ) + .unwrap(); + s.update( + &p, + scenario::Event::Source(child(&open, source::Edit::Back)), + ) + .unwrap(); + let after = s.recovery_snapshot().unwrap().unwrap(); + for field in ["name", "target", "owner", "parent", "before", "invocation"] { + assert_eq!(before[field], after[field]); + } + assert!( + s.update(&p, scenario::Event::Source(child(&open, source::Edit::Use))) + .is_err() + ); + assert_eq!(s.recovery_snapshot().unwrap().unwrap(), after); + assert_eq!(p.elements.len(), 1); +} + +#[test] +fn contextual_source_recovery_keeps_inner_raw_fields_and_refuses_deleted_or_changed_owner() { + let (mut p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let open = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + scenario::Event::Source(child(&open, source::Edit::Name(" 未完成\n\0".into()))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + let mut recovered = scenario::State::from_recovery(raw.clone()).unwrap(); + assert!(recovered.source_open()); + assert_eq!(recovered.recovery_snapshot().unwrap().unwrap(), raw); + p.elements.remove(&owner); + assert!(recovered.prepare_pending(&p).is_err()); + assert!( + recovered + .update(&p, scenario::Event::Source(child(&raw, source::Edit::Use))) + .is_err() + ); + assert_eq!(recovered.recovery_snapshot().unwrap().unwrap(), raw); +} + +#[test] +fn contextual_source_existing_choice_prunes_staged_dependency() { + let (mut p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let existing = p + .add_element(Kind::Activity, Layer::Oa, "Same name", None) + .unwrap(); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + scenario_source(&mut s, &p, "Same name", None); + s.update( + &p, + scenario::Event::Target(crate::app::Choice { + id: existing, + label: "Same name".into(), + }), + ) + .unwrap(); + let result = history(&p, s.prepare_pending(&p).unwrap()); + assert_eq!(result.elements.len(), p.elements.len() + 1); + let line = result + .children(owner) + .find(|e| e.kind() == Kind::Lifeline) + .unwrap(); + assert!( + matches!(line.shared(),Some(SharedBody::Lifeline{represented:Some(id)}) if *id==existing) + ); +} + +#[test] +fn contextual_source_machine_before_any_owner_creates_optional_view_atomically() { + for (layer, kind) in [(Layer::Oa, Kind::Actor), (Layer::Sa, Kind::SystemActor)] { + let p = Project::empty(); + let mut s = creation::State::default(); + s.update(&p, creation::Event::Open(layer)).unwrap(); + s.update(&p, creation::Event::Choose(creation::Family::Machine)) + .unwrap(); + s.update(&p, creation::Event::Name("Operating modes 雨".into())) + .unwrap(); + s.update(&p, creation::Event::FirstView(true)).unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + creation::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + creation::Event::Source(child(&raw, source::Edit::Kind(kind))), + ) + .unwrap(); + s.update( + &p, + creation::Event::Source(child( + &raw, + source::Edit::Name("Rescue participant 雨".into()), + )), + ) + .unwrap(); + s.update(&p, creation::Event::Source(child(&raw, source::Edit::Use))) + .unwrap(); + let command = s.prepare_pending(&p).unwrap(); + assert_eq!(command, s.prepare_pending(&p).unwrap()); + let target = s.created_by(&p, &command).unwrap(); + assert_eq!( + creation::State::from_recovery(s.recovery_snapshot().unwrap().unwrap()) + .unwrap() + .prepare_pending(&p) + .unwrap(), + command + ); + let result = history(&p, command); + assert_eq!(result.elements.len(), 2); + assert_eq!(result.diagrams.len(), 1); + let machine = &result.elements[&target.behavior]; + assert_eq!(machine.kind(), Kind::StateMachine); + let owner = &result.elements[&machine.parent.unwrap()]; + assert_eq!(owner.kind(), kind); + assert_eq!(owner.name, "Rescue participant 雨"); + let diagram = &result.diagrams[&target.diagram.unwrap()]; + assert_eq!( + diagram.projection, + ProjectionKind::StateMachine { + machine: target.behavior + } + ); + assert_eq!(diagram.nodes.len(), 1); + assert_eq!( + diagram.nodes.values().next().unwrap().element, + target.behavior + ); + } +} + +#[test] +fn contextual_source_second_system_and_role_machine_owner_refuse_without_fallback() { + let mut p = Project::empty(); + p.add_element(Kind::System, Layer::Sa, "Existing system", None) + .unwrap(); + let mut source = source::State::default(); + let scope = source::Scope::Machine { layer: Layer::Sa }; + let invocation = Id::new_v4(); + source + .update(&p, scope, invocation, source::Event::Open(invocation)) + .unwrap(); + let raw = serde_json::to_value(&source).unwrap(); + let token = id(&raw["editing"], "token"); + source + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Kind(Kind::System)), + ) + .unwrap(); + source + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Name("Illegal second system".into())), + ) + .unwrap(); + let before = serde_json::to_value(&source).unwrap(); + assert!( + source + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Use) + ) + .is_err() + ); + assert_eq!(serde_json::to_value(&source).unwrap(), before); + assert!( + source + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Kind(Kind::Role)) + ) + .is_err() + ); +} + +#[test] +fn contextual_source_work_uses_reserved_contribution_and_link_ids_and_one_undo() { + for layer in [Layer::Oa, Layer::Sa] { + let mut p = Project::empty(); + let owner = p + .add_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + "Response path", + None, + ) + .unwrap(); + let mut s = topology::State::default(); + s.update( + &p, + topology::Event::Start(owner, topology::Tool::Work, None), + ) + .unwrap(); + s.update(&p, topology::Event::Name("New contribution".into())) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + topology::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + topology::Event::Source(child(&raw, source::Edit::Name("New semantic work".into()))), + ) + .unwrap(); + s.update(&p, topology::Event::Source(child(&raw, source::Edit::Use))) + .unwrap(); + let command = s.prepare_pending(&p).unwrap(); + assert_eq!(command, s.prepare_pending(&p).unwrap()); + assert_eq!( + topology::State::from_recovery(s.recovery_snapshot().unwrap().unwrap()) + .unwrap() + .prepare_pending(&p) + .unwrap(), + command + ); + let selected = s.created_selection(&p, &command).unwrap(); + let result = history(&p, command); + assert_eq!(result.elements.len(), 3); + let occurrence = &result.elements[&selected]; + assert_eq!(occurrence.parent, Some(owner)); + assert_eq!(occurrence.name, "New contribution"); + let Some(SharedBody::Occurrence { + body: + OccurrenceBody::Action { + action: Some(work), + lifeline: None, + }, + .. + }) = occurrence.shared() + else { + panic!("Action contribution") + }; + assert_eq!(result.elements[work].name, "New semantic work"); + assert_eq!(result.elements[work].parent, None); + assert_eq!(result.elements[&owner].steps().unwrap(), &vec![selected]); + } +} + +#[test] +fn contextual_source_failed_source_budget_preserves_both_drafts_and_accepted_bytes() { + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + scenario_source(&mut s, &p, &"Long raw semantic name 雨".repeat(40), None); + let initial = Document::from_project(p.clone(), ProjectLimits::default()).unwrap(); + let bytes: usize = initial.bundle().files.values().map(String::len).sum(); + let limits = ProjectLimits { + model: language::ParseLimits { + max_source_bytes: bytes + 16, + ..language::ParseLimits::UNLIMITED + }, + ..ProjectLimits::default() + }; + let mut doc = Document::from_bundle(initial.bundle().clone(), limits).unwrap(); + let raw = s.recovery_snapshot().unwrap(); + assert!(doc.execute(s.prepare_pending(&p).unwrap()).is_err()); + assert_eq!(doc.project(), &p); + assert_eq!(doc.bundle(), initial.bundle()); + assert!(!doc.can_undo()); + assert_eq!(s.recovery_snapshot().unwrap(), raw); +} + +#[test] +fn contextual_source_owner_first_can_return_to_an_unnamed_machine_without_losing_source() { + let p = Project::empty(); + let mut s = creation::State::default(); + s.update(&p, creation::Event::Open(Layer::Oa)).unwrap(); + s.update(&p, creation::Event::Choose(creation::Family::Machine)) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + creation::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + creation::Event::Source(child(&raw, source::Edit::Kind(Kind::Actor))), + ) + .unwrap(); + s.update( + &p, + creation::Event::Source(child( + &raw, + source::Edit::Name("Owner entered first 雨".into()), + )), + ) + .unwrap(); + s.update(&p, creation::Event::Source(child(&raw, source::Edit::Use))) + .unwrap(); + assert!(!s.source_open()); + assert!(s.prepare_pending(&p).is_err()); + let staged = s.recovery_snapshot().unwrap().unwrap(); + s.update(&p, creation::Event::Name("Now name the machine".into())) + .unwrap(); + let result = history(&p, s.prepare_pending(&p).unwrap()); + let owner = id(&staged["source"]["staged"], "id"); + assert_eq!(result.named(owner), "Owner entered first 雨"); +} + +#[test] +fn contextual_source_inserts_work_on_the_exact_guarded_link_and_refuses_exchange_identity_conflict() +{ + let mut f = topology::direct_tests::fixture(Layer::Sa); + let start = |p: &Project| { + let mut s = topology::State::default(); + s.begin( + p, + topology::direct_tests::context(f.owner), + topology::Tool::Work, + ControlKind::Choice, + topology::Insertion { + link: Some(f.link), + before: Some(f.nodes[1]), + }, + ) + .unwrap(); + s + }; + let mut s = start(&f.p); + s.update( + &f.p, + topology::Event::Name(" New contribution 雨 ".into()), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &f.p, + topology::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &f.p, + topology::Event::Source(child(&raw, source::Edit::Name("Inserted function".into()))), + ) + .unwrap(); + s.update( + &f.p, + topology::Event::Source(child(&raw, source::Edit::Use)), + ) + .unwrap(); + let command = s.prepare_pending(&f.p).unwrap(); + assert_eq!(command, s.prepare_pending(&f.p).unwrap()); + let selected = s.created_selection(&f.p, &command).unwrap(); + let result = history(&f.p, command); + let link = &result.relations[&f.link]; + assert_eq!(link.source(), f.nodes[0]); + assert_eq!(link.target(), selected); + assert!(matches!(&link.body,RelationBody::SequenceLink{guard,..} if guard=="approved")); + assert_eq!(result.elements[&selected].name, " New contribution 雨 "); + assert!( + result + .relations + .values() + .any(|r| r.kind() == RelationKind::SequenceLink + && r.source() == selected + && r.target() == f.nodes[1]) + ); + for work in f.work { + assert_eq!(result.elements[&work], f.p.elements[&work]); + } + // The preceding exchange fixes its target work identity. A new source cannot match it. + if let Some(SharedBody::Occurrence { body, .. }) = + f.p.elements.get_mut(&f.nodes[0]).unwrap().shared_mut() + { + *body = OccurrenceBody::Exchange { + exchange: Some(f.exchange), + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }; + } + let mut s = start(&f.p); + s.update(&f.p, topology::Event::Name("Retain this slot".into())) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &f.p, + topology::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &f.p, + topology::Event::Source(child( + &raw, + source::Edit::Name("Cannot replace exchange endpoint".into()), + )), + ) + .unwrap(); + let before = s.recovery_snapshot().unwrap(); + assert!( + s.update( + &f.p, + topology::Event::Source(child(&raw, source::Edit::Use)) + ) + .is_err() + ); + assert_eq!(s.recovery_snapshot().unwrap(), before); + assert!(s.source_open()); +} + +#[test] +fn contextual_source_app_empty_actions_and_child_open_refuse_other_raw_editors() { + use crate::app::{App, Message, Page}; + for property in [true, false] { + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut app = App::with_project(p); + app.page = Page::Behavior; + if property { + app.draft + .as_mut() + .unwrap() + .input("/name", "Valid unapplied name".into()); + } else { + app.source_workspace.edit( + format!("{}\n// retained draft\n", app.source_workspace.text()), + app.source_editor.cursor(), + ); + } + let project = app.project().clone(); + let bundle = app.document.bundle().clone(); + let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let workspace = serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Start(owner, scenario::Tool::Lifeline), + ))); + assert!(!app.behavior.has_pending()); + assert_eq!(app.project(), &project); + assert_eq!(app.document.bundle(), &bundle); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + raw + ); + assert_eq!( + serde_json::to_value(app.source_workspace.recovery_snapshot()).unwrap(), + workspace + ); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn contextual_source_app_modal_escape_then_child_back_then_parent_cancel_preserve_accepted_source() +{ + use crate::app::{App, Message, Page, Pending}; + for file in [false, true] { + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut app = App::with_project(p.clone()); + app.page = Page::Behavior; + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Start(owner, scenario::Tool::Lifeline), + ))); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Name("Keep parent name 雨".into()), + ))); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ))); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Source(child( + &raw, + source::Edit::Name("Uncommitted source 雨".into()), + )), + ))); + let before = app.behavior.recovery_snapshot().unwrap(); + let bundle = app.document.bundle().clone(); + if file { + app.file_prompt = Some(crate::app::FilePrompt { + operation: crate::app::FileOperation::Save, + path: "Unsubmitted.arcadia".into(), + overwrite: false, + }); + } else { + app.pending = Some(Pending::Walkthrough); + } + let _ = app.update(Message::Escape); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), before); + assert!(app.file_prompt.is_none()); + assert!(app.pending.is_none()); + let _ = app.update(Message::Escape); + assert!(!app.behavior.source_open()); + assert!(app.behavior.has_pending()); + assert_eq!( + app.behavior.scenario.recovery_snapshot().unwrap().unwrap()["name"], + "Keep parent name 雨" + ); + let _ = app.update(Message::Escape); + assert!(!app.behavior.has_pending()); + assert_eq!(app.project(), &p); + assert_eq!(app.document.bundle(), &bundle); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn contextual_source_app_checkpoint_restores_inner_raw_draft_without_model_adoption() { + use crate::app::{App, Message, Page}; + use std::sync::Arc; + let (p, owner) = scenario_project(Layer::Sa, ScenarioViewpoint::Functional); + let mut app = App::with_project(p.clone()); + app.page = Page::Behavior; + app.layer = Layer::Sa; + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Start(owner, scenario::Tool::Lifeline), + ))); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ))); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Source(child(&raw, source::Edit::Name(" exact 未完成\n".into()))), + ))); + let raw = app.behavior.recovery_snapshot().unwrap(); + let snapshot = crate::recovery::Snapshot::capture( + Id::new_v4(), + &app.document, + &app.source_workspace, + app.draft.as_ref(), + &app.behavior, + None, + owner, + ) + .unwrap(); + let mut recovered = App::with_project(Project::empty()); + let task = recovered.update(Message::CheckpointLoaded(Ok(Arc::new(snapshot)))); + assert_eq!(recovered.project(), &p); + assert_eq!(recovered.layer, Layer::Sa); + assert_eq!(recovered.page, Page::Behavior); + assert!(recovered.behavior.source_open()); + assert_eq!(recovered.behavior.recovery_snapshot().unwrap(), raw); + assert!(!recovered.document.can_undo()); + assert!( + iced_runtime::task::into_stream(task).is_some(), + "Recovery preserves its lint/focus task" + ); +} + +#[test] +fn contextual_source_mounted_small_editor_keeps_back_and_use_outside_field_scroll() { + use iced::advanced::{Layout, layout, widget}; + use iced::{Rectangle, Size, Vector}; + struct Geometry { + actions: Option, + fields: Option, + } + impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("behavior-source-actions")) { + self.actions = Some(bounds); + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: Rectangle, + _: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&widget::Id::new("behavior-source-fields")) { + self.fields = Some(bounds); + } + } + } + let p = Project::empty(); + let scope = source::Scope::Machine { layer: Layer::Oa }; + let invocation = Id::new_v4(); + let mut state = source::State::default(); + state + .update(&p, scope, invocation, source::Event::Open(invocation)) + .unwrap(); + for size in [Size::new(340., 320.), Size::new(500., 570.)] { + let mut view: iced::Element<'_, source::Event, iced::Theme, ()> = state.view(&p).unwrap(); + let mut tree = widget::Tree::new(view.as_widget()); + let node = + view.as_widget_mut() + .layout(&mut tree, &(), &layout::Limits::new(Size::ZERO, size)); + let mut geometry = Geometry { + actions: None, + fields: None, + }; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), &mut geometry); + let actions = geometry.actions.unwrap(); + let fields = geometry.fields.unwrap(); + assert!( + actions.x >= 0. + && actions.y >= 0. + && actions.x + actions.width <= size.width + && actions.y + actions.height <= size.height + ); + assert!(fields.height >= 100. && fields.y + fields.height <= actions.y); + } +} + +#[test] +fn contextual_source_back_remains_available_after_owner_deletion_and_with_dirty_editors() { + use crate::app::{App, Message, Page}; + let (mut p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut state = scenario::State::default(); + state + .update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + state + .update(&p, scenario::Event::Name("Retain parent".into())) + .unwrap(); + let raw = state.recovery_snapshot().unwrap().unwrap(); + state + .update( + &p, + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let open = state.recovery_snapshot().unwrap().unwrap(); + p.elements.remove(&owner); + state + .update( + &p, + scenario::Event::Source(child(&open, source::Edit::Back)), + ) + .unwrap(); + assert!(!state.source_open()); + assert_eq!( + state.recovery_snapshot().unwrap().unwrap()["name"], + "Retain parent" + ); + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut app = App::with_project(p.clone()); + app.page = Page::Behavior; + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Start(owner, scenario::Tool::Lifeline), + ))); + let raw = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ))); + let open = app.behavior.scenario.recovery_snapshot().unwrap().unwrap(); + app.draft + .as_mut() + .unwrap() + .input("/name", "Other raw input".into()); + let property = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(); + let _ = app.update(Message::Behavior(super::Event::Scenario( + scenario::Event::Source(child(&open, source::Edit::Back)), + ))); + assert!(!app.behavior.source_open()); + assert!(app.behavior.has_pending()); + assert_eq!( + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(), + property + ); + assert_eq!(app.project(), &p); + assert!(!app.document.can_undo()); +} + +#[test] +fn contextual_source_incomplete_recovered_kind_reports_error_without_panicking() { + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + scenario_source(&mut s, &p, "Recovered source", None); + let mut raw = s.recovery_snapshot().unwrap().unwrap(); + let mut wrong_scope = raw.clone(); + wrong_scope["source"]["staged"]["scope"] = + serde_json::to_value(source::Scope::Machine { layer: Layer::Oa }).unwrap(); + assert!( + scenario::State::from_recovery(wrong_scope) + .unwrap() + .prepare_pending(&p) + .unwrap_err() + .contains("different consumer context") + ); + raw["source"]["staged"]["kind"] = serde_json::Value::Null; + let recovered = scenario::State::from_recovery(raw.clone()).unwrap(); + assert!(recovered.prepare_pending(&p).is_err()); + let selected = serde_json::from_value(raw["target"].clone()).unwrap(); + let nested: source::State = serde_json::from_value(raw["source"].clone()).unwrap(); + assert!(nested.label(selected).unwrap().contains("Choose kind")); + assert_eq!(recovered.recovery_snapshot().unwrap().unwrap(), raw); +} + +#[test] +fn contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer() { + use crate::app::{App, Message, Page}; + let (mut p, a) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let b = p + .add_element(Kind::Scenario, Layer::Oa, "Second scenario", None) + .unwrap(); + let mut app = App::with_project(p.clone()); + app.page = Page::Behavior; + app.layer = Layer::Oa; + app.behavior.active_owner = Some(a); + app.selected = a; + let context = app.scenario_context().unwrap(); + app.behavior.active_owner = Some(b); + app.selected = b; + let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First( + context, + scenario::Tool::Lifeline, + ))); + assert!(!app.behavior.has_pending()); + assert_eq!(app.project(), &p); + app.behavior.active_owner = Some(a); + app.selected = a; + app.layer = Layer::Sa; + let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First( + context, + scenario::Tool::Lifeline, + ))); + assert!(!app.behavior.has_pending()); + app.layer = Layer::Oa; + let current = app.scenario_context().unwrap(); + let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First( + current, + scenario::Tool::Lifeline, + ))); + assert_eq!(app.behavior.pending_owner(), Some(a)); + assert_eq!(app.project(), &p); + assert!(!app.document.can_undo()); +} + +#[test] +fn contextual_source_deleted_owner_view_retains_draft_instead_of_panicking_or_using_fallback() { + use crate::app::{App, Page}; + for fallback in [false, true] { + let (mut p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + if fallback { + p.add_element( + Kind::Scenario, + Layer::Oa, + "Different remaining scenario", + None, + ) + .unwrap(); + } + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + s.update(&p, scenario::Event::Name("Recovered consumer".into())) + .unwrap(); + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let open = s.recovery_snapshot().unwrap().unwrap(); + p.elements.remove(&owner); + let mut app = App::with_project(p.clone()); + app.page = Page::Behavior; + app.layer = Layer::Oa; + app.behavior.scenario = scenario::State::from_recovery(open.clone()).unwrap(); + drop(super::view(&app)); + assert!(app.behavior.source_open()); + assert_eq!( + app.behavior.scenario.recovery_snapshot().unwrap().unwrap(), + open + ); + assert_eq!(app.project(), &p); + assert!(app.behavior.source_back()); + drop(super::view(&app)); + assert!(app.behavior.has_pending()); + assert_eq!( + app.behavior.scenario.recovery_snapshot().unwrap().unwrap()["name"], + "Recovered consumer" + ); + } +} + +#[test] +fn contextual_source_nested_semantic_parent_is_explicit_and_revalidated() { + let (mut p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let parent = p + .add_element(Kind::Activity, Layer::Oa, "Parent activity", None) + .unwrap(); + let wrong = p + .add_element( + Kind::Entity, + Layer::Oa, + "Participant is not work ownership", + None, + ) + .unwrap(); + let scope = source::Scope::Lifeline { + owner, + viewpoint: ScenarioViewpoint::OperationalActivity, + }; + let invocation = Id::new_v4(); + let mut state = source::State::default(); + state + .update(&p, scope, invocation, source::Event::Open(invocation)) + .unwrap(); + let raw = serde_json::to_value(&state).unwrap(); + let token = id(&raw["editing"], "token"); + assert!( + state + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Parent(Some(wrong))) + ) + .is_err() + ); + state + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Parent(Some(parent))), + ) + .unwrap(); + state + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Name("Explicit child work".into())), + ) + .unwrap(); + let new = state + .update( + &p, + scope, + invocation, + source::Event::Edit(token, source::Edit::Use), + ) + .unwrap() + .unwrap(); + let preview = state.preview(&p, Some(new)).unwrap().unwrap(); + assert_eq!(preview.elements[&new].parent, Some(parent)); + assert_eq!(preview.elements[&new].kind(), Kind::Activity); + assert_eq!(p.elements.len() + 1, preview.elements.len()); + let raw = serde_json::to_value(&state).unwrap(); + p.elements.get_mut(&parent).unwrap().body = ElementBody::new(Kind::Entity, Layer::Oa).unwrap(); + assert!(state.preview(&p, Some(new)).is_err()); + assert_eq!(serde_json::to_value(&state).unwrap(), raw); +} + +#[test] +fn contextual_source_new_callee_lifeline_preserves_caller_bindings_as_an_explicit_warning() { + let (mut p, callee) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let caller = p + .add_element(Kind::Scenario, Layer::Oa, "Caller", None) + .unwrap(); + let call = topology::occurrence( + &p, + caller, + "Use callee", + OccurrenceBody::ScenarioUse { + scenario: Some(callee), + bindings: vec![], + }, + ); + let call_id = call.id; + p = execute_batch( + &p, + &[ + Command::CreateElement(call), + Command::SetSteps(caller, vec![call_id]), + ], + ) + .unwrap(); + let mut state = scenario::State::default(); + state + .update(&p, scenario::Event::Start(callee, scenario::Tool::Lifeline)) + .unwrap(); + scenario_source(&mut state, &p, "New represented activity", None); + let result = history(&p, state.prepare_pending(&p).unwrap()); + for (id, element) in &p.elements { + assert_eq!(result.elements[id], *element); + } + assert!( + validation::validate(&result) + .iter() + .any(|issue| issue.element == Some(call_id) + && issue.code == "missing_lifeline_binding" + && issue.severity == validation::Severity::Warning) + ); + assert_eq!(result.relations, p.relations); + assert_eq!(result.diagrams, p.diagrams); +} + +#[test] +fn contextual_source_empty_history_reports_no_change_and_success_requires_a_real_history_step() { + use crate::app::{App, Message, Page}; + use std::sync::Arc; + let (p, owner) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let mut app = App::with_project(p.clone()); + app.page = Page::Behavior; + app.selected = owner; + app.behavior.active_owner = Some(owner); + let before = app.document.snapshot(); + let bundle = app.document.bundle().clone(); + for (message, status) in [ + (Message::Undo, "Nothing to undo"), + (Message::Redo, "Nothing to redo"), + ] { + let _ = app.update(message); + assert_eq!(app.status, status); + assert!(Arc::ptr_eq(&before, &app.document.snapshot())); + assert_eq!(app.document.bundle(), &bundle); + assert_eq!(app.selected, owner); + assert_eq!(app.behavior.active_owner, Some(owner)); + } + let _ = app.update(Message::Command(Command::Rename( + owner, + "Accepted renamed scenario".into(), + ))); + assert_eq!(app.project().named(owner), "Accepted renamed scenario"); + let _ = app.update(Message::Undo); + assert_eq!(app.status, "Model change undone"); + assert_eq!(app.project(), &p); + let _ = app.update(Message::Redo); + assert_eq!(app.status, "Model change redone"); + assert_eq!(app.project().named(owner), "Accepted renamed scenario"); + let _ = app.update(Message::Redo); + assert_eq!(app.status, "Nothing to redo"); +} + +#[test] +fn contextual_source_original_document_remains_bound_after_back_or_choosing_existing() { + for legacy in [false, true] { + for inactive in [false, true] { + let (mut p, owner) = + scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity); + let existing = p + .add_element(Kind::Activity, Layer::Oa, "Existing activity", None) + .unwrap(); + let unrelated = p + .add_element(Kind::Scenario, Layer::Oa, "Another valid scenario", None) + .unwrap(); + let choice = || crate::app::Choice { + id: existing, + label: "Existing activity".into(), + }; + let mut s = scenario::State::default(); + s.update(&p, scenario::Event::Start(owner, scenario::Tool::Lifeline)) + .unwrap(); + s.update( + &p, + scenario::Event::Name("Retained valid lifeline 雨".into()), + ) + .unwrap(); + s.update(&p, scenario::Event::Target(choice())).unwrap(); + if legacy { + let mut raw = s.recovery_snapshot().unwrap().unwrap(); + raw.as_object_mut().unwrap().remove("source_origin"); + s = scenario::State::from_recovery(raw).unwrap(); + } + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &p, + scenario::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let open = s.recovery_snapshot().unwrap().unwrap(); + assert_eq!( + open["source_origin"][0], + serde_json::to_value(p.id).unwrap() + ); + if inactive { + s.update( + &p, + scenario::Event::Source(child( + &open, + source::Edit::Name("Unused staged activity".into()), + )), + ) + .unwrap(); + s.update(&p, scenario::Event::Source(child(&open, source::Edit::Use))) + .unwrap(); + s.update(&p, scenario::Event::Target(choice())).unwrap(); + } else { + s.update( + &p, + scenario::Event::Source(child(&open, source::Edit::Back)), + ) + .unwrap(); + } + assert!( + s.prepare_pending(&p).is_ok(), + "Control must have a valid existing target" + ); + let raw = s.recovery_snapshot().unwrap().unwrap(); + let mut other = p.clone(); + other.id = Id::new_v4(); + assert!( + s.prepare_pending(&other) + .unwrap_err() + .contains("original consumer document") + ); + assert_eq!(s.recovery_snapshot().unwrap().unwrap(), raw); + let mut borrowed = raw.clone(); + borrowed["source_origin"][1] = serde_json::to_value(&p.elements[&unrelated]).unwrap(); + let mut wrong = scenario::State::from_recovery(borrowed.clone()).unwrap(); + assert!( + wrong + .prepare_pending(&p) + .unwrap_err() + .contains("original consumer") + ); + assert!( + wrong + .update( + &p, + scenario::Event::Source(source::Event::Open(id(&borrowed, "invocation"))) + ) + .is_err() + ); + assert_eq!(wrong.recovery_snapshot().unwrap().unwrap(), borrowed); + let mut malformed = raw.clone(); + malformed["source_origin"] = serde_json::Value::Null; + assert!( + scenario::State::from_recovery(malformed) + .unwrap() + .prepare_pending(&p) + .is_err() + ); + + let mut f = topology::direct_tests::fixture(Layer::Sa); + let unrelated = + f.p.add_element( + Kind::FunctionalChain, + Layer::Sa, + "Another valid chain", + None, + ) + .unwrap(); + let choice = || crate::app::Choice { + id: f.work[0], + label: "Existing function".into(), + }; + let mut s = topology::State::default(); + s.update( + &f.p, + topology::Event::Start(f.owner, topology::Tool::Work, None), + ) + .unwrap(); + s.update(&f.p, topology::Event::Target(choice())).unwrap(); + s.update( + &f.p, + topology::Event::Name("Retained valid contribution 雨".into()), + ) + .unwrap(); + if legacy { + let mut raw = s.recovery_snapshot().unwrap().unwrap(); + raw.as_object_mut().unwrap().remove("source_origin"); + s = topology::State::from_recovery(raw).unwrap(); + } + let raw = s.recovery_snapshot().unwrap().unwrap(); + s.update( + &f.p, + topology::Event::Source(source::Event::Open(id(&raw, "invocation"))), + ) + .unwrap(); + let open = s.recovery_snapshot().unwrap().unwrap(); + assert_eq!( + open["source_origin"][0], + serde_json::to_value(f.p.id).unwrap() + ); + if inactive { + s.update( + &f.p, + topology::Event::Source(child( + &open, + source::Edit::Name("Unused staged function".into()), + )), + ) + .unwrap(); + s.update( + &f.p, + topology::Event::Source(child(&open, source::Edit::Use)), + ) + .unwrap(); + s.update(&f.p, topology::Event::Target(choice())).unwrap(); + } else { + s.update( + &f.p, + topology::Event::Source(child(&open, source::Edit::Back)), + ) + .unwrap(); + } + assert!(s.prepare_pending(&f.p).is_ok()); + let raw = s.recovery_snapshot().unwrap().unwrap(); + let mut other = f.p.clone(); + other.id = Id::new_v4(); + assert!( + s.prepare_pending(&other) + .unwrap_err() + .contains("original consumer document") + ); + assert_eq!(s.recovery_snapshot().unwrap().unwrap(), raw); + let mut borrowed = raw.clone(); + borrowed["source_origin"][1] = serde_json::to_value(&f.p.elements[&unrelated]).unwrap(); + let mut wrong = topology::State::from_recovery(borrowed.clone()).unwrap(); + assert!( + wrong + .prepare_pending(&f.p) + .unwrap_err() + .contains("original consumer") + ); + assert!( + wrong + .update( + &f.p, + topology::Event::Source(source::Event::Open(id(&borrowed, "invocation"))) + ) + .is_err() + ); + assert_eq!(wrong.recovery_snapshot().unwrap().unwrap(), borrowed); + let mut malformed = raw.clone(); + malformed["source_origin"] = serde_json::Value::Null; + assert!( + topology::State::from_recovery(malformed) + .unwrap() + .prepare_pending(&f.p) + .is_err() + ); + } + } +} diff --git a/crates/oa-desktop/src/behavior/creation.rs b/crates/oa-desktop/src/behavior/creation.rs index b78776fe..d30281a4 100644 --- a/crates/oa-desktop/src/behavior/creation.rs +++ b/crates/oa-desktop/src/behavior/creation.rs @@ -75,6 +75,7 @@ impl Picker { } #[derive(Debug, Clone)] pub enum Event { + Source(super::source_draft::Event), Open(Layer), Choose(Family), Name(String), @@ -97,6 +98,11 @@ struct Browser { #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Draft { + #[serde(default)] + source: super::source_draft::State, + #[serde(default = "Id::new_v4")] + invocation: Id, + version: u32, document: Id, layer: Layer, @@ -125,6 +131,17 @@ pub struct Created { } impl State { + pub fn source_active(&self) -> bool { + self.draft.as_ref().is_some_and(|d| d.source.engaged()) + } + + pub fn source_open(&self) -> bool { + self.draft.as_ref().is_some_and(|d| d.source.is_open()) + } + pub fn source_back(&mut self) -> bool { + self.draft.as_mut().is_some_and(|d| d.source.back()) + } + pub fn has_pending(&self) -> bool { self.draft.is_some() } @@ -174,6 +191,49 @@ impl State { .command(p) } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + if let Event::Source(event) = event { + if self + .draft + .as_mut() + .ok_or("Start the consumer form first.")? + .source + .dismiss(&event)? + { + return Ok(None); + } + let d = self + .draft + .as_ref() + .ok_or("Start the consumer form first.")?; + if d.family != Some(Family::Machine) { + return Err("This reference does not support staged source creation.".into()); + } + if d.document != p.id { + return Err("This behavior draft belongs to another document.".into()); + } + let mut next = d.clone(); + if let Some(id) = next.source.update( + p, + super::source_draft::Scope::Machine { layer: d.layer }, + d.invocation, + event, + )? { + next.owner = Some(id); + let preview = next + .source + .preview(p, next.owner)? + .ok_or("New owner preview is missing.")?; + if !eligible(&preview, next.layer, Picker::Owner, id) { + return Err("This participant cannot own the machine.".into()); + } + } + self.draft = Some(next); + return Ok(None); + } + if self.source_open() && !matches!(&event, Event::Cancel) { + return Err("Use the new source or go Back before editing the parent form.".into()); + } + match event { Event::Cancel => { self.draft = None; @@ -184,6 +244,8 @@ impl State { return Err("Create or cancel the pending behavior first.".into()); } self.draft = Some(Draft { + source: Default::default(), + invocation: Id::new_v4(), version: 1, document: p.id, layer, @@ -332,7 +394,7 @@ fn reference_context(p: &Project, e: &Element) -> String { }; format!("{} · {} · {location}", e.kind(), e.alias) } -fn reference_label(p: &Project, id: Id) -> String { +pub(super) fn reference_label(p: &Project, id: Id) -> String { p.elements .get(&id) .map(|e| format!("{} · {}", e.name, reference_context(p, e))) @@ -340,6 +402,15 @@ fn reference_label(p: &Project, id: Id) -> String { } impl Draft { fn command(&self, p: &Project) -> Result { + self.source.check_scope( + super::source_draft::Scope::Machine { layer: self.layer }, + self.owner, + )?; + let preview = self.source.preview(p, self.owner)?; + let command = self.consumer_command(preview.as_ref().unwrap_or(p))?; + self.source.combine(p, self.owner, command) + } + fn consumer_command(&self, p: &Project) -> Result { if self.document != p.id { return Err("This draft belongs to another document.".into()); } @@ -475,6 +546,21 @@ impl State { p: &'a Project, ) -> Option> { let d = self.draft.as_ref()?; + if let Some(view) = d.source.view(p) { + return Some( + column![ + text(format!( + "State machine name: {}", + crate::views::short(&d.name, 60) + )) + .size(12), + view.map(Event::Source) + ] + .spacing(6) + .height(Fill) + .into(), + ); + } let mut fields = column![].spacing(10); let title = if let Some(family) = d.family { fields = fields.push( @@ -488,7 +574,7 @@ impl State { Family::Sequence => { fields = fields.push( text(format!( - "Owned by the {} layer root. Involve existing work after creation.", + "Owned by the {} layer root. Add work and exchange contributions after creation.", d.layer )) .size(12), @@ -531,7 +617,17 @@ impl State { } } Family::Machine => { + if let Some(label) = d.source.label(d.owner) { + fields = fields.push(text(label).size(13)).push(text("Create and open commits the new owner and machine together, including the first view only when selected.").size(12)); + } fields = fields.push(picker_view(p, d, Picker::Owner)); + fields = fields.push( + button("Create new owner…") + .on_press(Event::Source(super::source_draft::Event::Open( + d.invocation, + ))) + .padding(7), + ); fields = fields.push( checkbox(d.first_view) .label("Create the first state view") @@ -544,9 +640,9 @@ impl State { } else { for family in [Family::Sequence, Family::Scenario, Family::Machine] { let purpose = match family { - Family::Sequence => "A path through existing work and exchanges", + Family::Sequence => "A path through work and exchanges", Family::Scenario => "Ordered interactions from an explicit viewpoint", - Family::Machine => "States and modes of an existing participant", + Family::Machine => "States and modes of an explicitly chosen participant", }; fields = fields.push( button( @@ -598,7 +694,9 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>( Picker::Owner => d.owner, Picker::Context => d.context, }; + let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some(); let title = match picker { + Picker::Owner if staged_owner => "Or choose an existing participant", Picker::Owner => "Owned by participant", Picker::Context => "Scenario context · optional", }; @@ -609,14 +707,17 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>( .padding(6) ] .spacing(5); - if let Some(id) = selected { + if let Some(id) = selected + && !staged_owner + { out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12)); } else if picker == Picker::Context { out = out.push(text("No context selected").size(12)); } if options.is_empty() { out = out.push(text(match picker { - Picker::Owner => "No matching participant. Refine the search, or cancel and create a participant in Architecture first.", + Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.", + Picker::Owner => "No matching participant. Refine the search or choose Create new owner.", Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.", }).size(12)); } diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 82f37e95..63f08848 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -94,6 +94,7 @@ impl std::fmt::Display for CanvasTool { } #[derive(Debug, Clone)] pub enum CanvasEvent { + First(Context, Tool), Scrolled(Context, ScrollArea, iced::Vector), Restored(Context, Id, ScrollArea, iced::Vector), Viewport(Context, Size), @@ -132,7 +133,8 @@ pub enum ScrollArea { impl CanvasEvent { pub fn context(&self) -> Context { match self { - Self::Scrolled(context, _, _) + Self::First(context, _) + | Self::Scrolled(context, _, _) | Self::Restored(context, ..) | Self::Viewport(context, _) | Self::Zoom(context, _) @@ -162,6 +164,7 @@ impl std::fmt::Display for Operator { } #[derive(Debug, Clone)] pub enum Event { + Source(super::source_draft::Event), Start(Id, Tool), Cancel, Submit, @@ -240,6 +243,13 @@ struct CallChoiceContext { #[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct Draft { + #[serde(default)] + source: super::source_draft::State, + #[serde(default = "Id::new_v4")] + invocation: Id, + #[serde(default)] + source_origin: Option<(Id, oa_core::Element)>, + owner: Id, parent: Id, before: Option, @@ -272,6 +282,28 @@ struct Draft { choice_page: usize, } impl State { + pub(super) fn pending_name(&self) -> Option<&str> { + self.draft.as_ref().map(|d| d.name.as_str()) + } + pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option> { + self.draft + .as_ref()? + .source + .view(p) + .map(|v| v.map(Event::Source)) + } + + pub fn source_active(&self) -> bool { + self.draft.as_ref().is_some_and(|d| d.source.engaged()) + } + + pub fn source_open(&self) -> bool { + self.draft.as_ref().is_some_and(|d| d.source.is_open()) + } + pub fn source_back(&mut self) -> bool { + self.draft.as_mut().is_some_and(|d| d.source.back()) + } + pub(crate) fn enter(&mut self, document: Id, owner: Id) { if self.presentation.identity != Some((document, owner)) { self.presentation = workspace::Presentation { @@ -452,8 +484,16 @@ impl State { } cached.as_ref().unwrap().1.clone() } - pub(crate) fn created_selection(&self, command: &Command) -> Option { + pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option { let draft = self.draft.as_ref()?; + if self + .draft + .as_ref() + .is_some_and(|d| d.source.label(d.target).is_some()) + && self.prepare_pending(p).ok().as_ref() != Some(command) + { + return None; + } fn created(command: &Command, parent: Id) -> Option { match command { Command::CreateElement(element) if element.parent == Some(parent) => { @@ -568,6 +608,61 @@ impl State { } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + if let Event::Source(event) = event { + if self + .draft + .as_mut() + .ok_or("Start the consumer form first.")? + .source + .dismiss(&event)? + { + return Ok(None); + } + let d = self + .draft + .as_ref() + .ok_or("Start the consumer form first.")?; + if d.tool != Tool::Lifeline { + return Err("This reference does not support staged source creation.".into()); + } + if d.source_origin.as_ref().is_some_and(|(document, owner)| { + *document != p.id || p.elements.get(&d.owner) != Some(owner) + }) { + return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into()); + } + if d.source_origin.is_none() && d.source.engaged() { + return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into()); + } + let mut next = d.clone(); + if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_)) + { + next.source_origin = Some(( + p.id, + p.elements + .get(&d.owner) + .ok_or("The consumer owner no longer exists.")? + .clone(), + )); + } + if let Some(id) = next.source.update( + p, + super::source_draft::Scope::Lifeline { + owner: d.owner, + viewpoint: viewpoint(p, d.owner), + }, + d.invocation, + event, + )? { + next.target = Some(id); + next.command(p)?; + } + self.draft = Some(next); + return Ok(None); + } + if self.source_open() && !matches!(&event, Event::Cancel) { + return Err("Use the new source or go Back before editing the parent form.".into()); + } + if self.draft.is_some() && matches!(&event, Event::Start(..)) { return Err(PENDING_DRAFT.into()); } @@ -577,6 +672,9 @@ impl State { return Err("Choose a scenario.".into()); } self.draft = Some(Draft { + source: Default::default(), + invocation: Id::new_v4(), + source_origin: Some((p.id, p.elements[&owner].clone())), owner, parent: owner, before: None, @@ -940,7 +1038,14 @@ fn valid_body_choice(p: &Project, owner: Id, body: OccurrenceBody) -> bool { execute_batch(p, &[Command::CreateElement(e)]).is_ok() } fn sources(p: &Project, d: &Draft) -> Vec { - let layer = p.elements[&d.owner].layer(); + let Some(owner) = p + .elements + .get(&d.owner) + .filter(|e| e.kind() == Kind::Scenario) + else { + return vec![]; + }; + let layer = owner.layer(); let mut values: Vec<_> = p .elements .values() @@ -1256,6 +1361,28 @@ impl Draft { e } fn command(&self, p: &Project) -> Result { + if self.source.engaged() + && self.source_origin.as_ref().is_none_or(|(document, owner)| { + *document != p.id || p.elements.get(&self.owner) != Some(owner) + }) + { + return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into()); + } + self.source.check_scope( + super::source_draft::Scope::Lifeline { + owner: self.owner, + viewpoint: viewpoint(p, self.owner), + }, + self.target, + )?; + let preview = self.source.preview(p, self.target)?; + let command = self.consumer_command(preview.as_ref().unwrap_or(p))?; + self.source.combine(p, self.target, command) + } + fn consumer_command(&self, p: &Project) -> Result { + if p.elements.get(&self.owner).map(oa_core::Element::kind) != Some(Kind::Scenario) { + return Err("The scenario no longer exists. Cancel or restore its owner.".into()); + } if !parents(p, self.owner).iter().any(|c| c.id == self.parent) { return Err("Choose this scenario or one of its operands as destination.".into()); } @@ -1266,6 +1393,8 @@ impl Draft { .unwrap_or(self.tool.label()); let name = if self.name.trim().is_empty() { default + } else if self.source.label(self.target).is_some() { + self.name.as_str() } else { self.name.trim() }; diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 121136ce..3265bc22 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -168,6 +168,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .wrap(), text(if pending { "Pending form · the highlighted sequence position is retained".to_owned() + } else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) { + "Start with a lifeline representing existing or explicitly new work / participants." + .into() + } else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) { + "Choose a first message, action or another tool; no occurrence is selected yet.".into() } else { let guidance = match tool { CanvasTool::Message(_) => { @@ -186,6 +191,32 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .size(11) ] .spacing(6); + if !pending { + if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) { + header = header.push( + row![ + text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12), + button("Add first lifeline").on_press_maybe(clean.then_some({ + Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline)) + })) + ] + .spacing(8), + ); + } else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) { + header = header.push( + row![ + text("No occurrences yet").size(12), + button("Add first message").on_press_maybe(clean.then_some({ + Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message)) + })), + button("Add first action").on_press_maybe(clean.then_some({ + Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action)) + })) + ] + .spacing(8), + ); + } + } if limited { header = header .push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11)); @@ -295,272 +326,301 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a .as_ref() .filter(|d| d.owner == owner) { - let mut form = column![ - text(d.tool.label()).size(16), - text_input("Name (defaults to selected source)", &d.name) - .on_input(|s| event(Event::Name(s))) - .padding(9) - .size(12) - ] - .spacing(9); - if d.tool != Tool::Lifeline { - form = form.push(label(format!( - "Insert: {}", - slot_label( - p, - Slot { - parent: d.parent, - before: d.before - } - ) - ))); - if d.before.is_some() { - form = form.push( - button("Append to this sequence instead").on_press(event(Event::AppendInstead)), - ); - } - form = form.push(picker( - parents(p, owner), - Some(d.parent), - "Destination sequence / operand…", - Event::Parent, - )); - } - if matches!( - d.tool, - Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation - ) { - form = form.push(picker( - sources(p, d), - d.target, - "Choose a named source…", - Event::Target, - )); - } - if d.tool == Tool::Message { - form = form.push( - pick_list( - vec![ - MessageStyle::Asynchronous, - MessageStyle::Call, - MessageStyle::CallReply, - MessageStyle::Reply, - MessageStyle::Create, - MessageStyle::Delete, - MessageStyle::Timer, - ], - Some(d.style), - |style| event(Event::Style(style)), - ) - .width(Fill) - .padding(9) - .text_size(12), - ); - if d.style == MessageStyle::Reply { - form = form.push(bounded_picker( - app.behavior.scenario.call_choices(p, context, d), - d, - d.call, - "Choose the earlier call…", - Event::Call, + if let Some(child) = d.source.view(p) { + form_panel = column![ + text(format!( + "Consumer name: {}", + crate::views::short(&d.name, 60) + )) + .size(12), + child.map(|e| event(Event::Source(e))) + ] + .spacing(6) + .height(Fill) + .into(); + } else { + let mut form = column![ + text(d.tool.label()).size(16), + text_input("Name (defaults to selected source)", &d.name) + .on_input(|s| event(Event::Name(s))) + .padding(9) + .size(12) + ] + .spacing(9); + if d.tool != Tool::Lifeline { + form = form.push(label(format!( + "Insert: {}", + slot_label( + p, + Slot { + parent: d.parent, + before: d.before + } + ) + ))); + if d.before.is_some() { + form = form.push( + button("Append to this sequence instead") + .on_press(event(Event::AppendInstead)), + ); + } + form = form.push(picker( + parents(p, owner), + Some(d.parent), + "Destination sequence / operand…", + Event::Parent, )); - if d.call.is_some() { - form = form.push(label( + } + if matches!( + d.tool, + Tool::Lifeline | Tool::Reuse | Tool::Action | Tool::Observation + ) { + form = form.push(picker( + sources(p, d), + d.target, + "Choose a named source…", + Event::Target, + )); + } + if d.tool == Tool::Lifeline { + if let Some(title) = d.source.label(d.target) { + form = form.push(label(title)).push(label( + "Create commits one new source and this lifeline together.", + )); + } + if viewpoint(p, owner) != ScenarioViewpoint::MixedLegacy { + form = form.push(button("Create new source…").on_press(event(Event::Source( + super::super::source_draft::Event::Open(d.invocation), + )))); + } else { + form=form.push(label("New source creation requires a strict viewpoint. Existing sources remain available.")); + } + } + if d.tool == Tool::Message { + form = form.push( + pick_list( + vec![ + MessageStyle::Asynchronous, + MessageStyle::Call, + MessageStyle::CallReply, + MessageStyle::Reply, + MessageStyle::Create, + MessageStyle::Delete, + MessageStyle::Timer, + ], + Some(d.style), + |style| event(Event::Style(style)), + ) + .width(Fill) + .padding(9) + .text_size(12), + ); + if d.style == MessageStyle::Reply { + form = form.push(bounded_picker( + app.behavior.scenario.call_choices(p, context, d), + d, + d.call, + "Choose the earlier call…", + Event::Call, + )); + if d.call.is_some() { + form = form.push(label( "Reply reuses the call exchange in reverse and retains its correlation.", )); - } - } else if d.style != MessageStyle::Timer { - form = form.push(bounded_picker( - exchanges(p, d), - d, - d.exchange, - "Choose the exchange…", - Event::Exchange, - )); - form = form.push(label( - "Apply checks that the message is valid at this insertion position.", - )); - } - form = form.push(picker( - d.line_choices(p, true), - d.sender, - "Sending lifeline…", - Event::Sender, - )); - if d.style != MessageStyle::Timer { - form = form.push(picker( - d.line_choices(p, false), - d.receiver, - "Receiving lifeline…", - Event::Receiver, - )); - } - if d.style == MessageStyle::Timer { - form = form.push( - text_input("Duration / deadline, e.g. 30 s", &d.expression) - .on_input(|s| event(Event::Expression(s))) - .padding(9) - .size(12), - ); - } - if p.layer_of(owner) == Some(Layer::Sa) - && viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface - && matches!( - d.style, - MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous - ) - { - form = form.push( - iced::widget::checkbox(d.operation.is_some()) - .label("Invoke a specific interface operation") - .on_toggle(|enabled| event(Event::OperationIntent(enabled))), - ); - if let Some(operation) = &d.operation { - form = form - .push(bounded_picker( - crate::data_values::operation_choices(p, &d.proposed_message(p)), - d, - operation.allocation, - "Choose the exact interface operation allocation…", - Event::Operation, - )) - .push( - button("Keep operation unresolved") - .on_press(event(Event::ClearOperation)), - ); - if let Some(id) = operation.allocation { - form = form.push(label(crate::data_values::operation_identity(p, id))); } - } - } - if let Some(description) = - crate::data_values::operation_label(p, &d.proposed_message(p)) - { - form = form.push(label(description)); - } - match d.signature(p) { - Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)), - Err(problem) => { - form = form.push(label(problem)); - for (key, expression) in d.authored_values() { - form = form - .push( - text(format!( - "Retained value · {} / {}", - key.path - .iter() - .map(|id| p.named(*id)) - .collect::>() - .join(" → "), - p.named(key.field) - )) - .size(11), - ) - .push( - text_input("Value expression", expression) - .on_input({ - let key = key.clone(); - move |raw| event(Event::Value(key.clone(), raw)) - }) - .padding(8) - .size(12), - ); - } - } - Ok(None) => {} - } - if d.style == MessageStyle::CallReply { - form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation.")); - } - } - if matches!(d.tool, Tool::Action | Tool::Observation) { - form = form.push(picker( - lines(p, owner), - d.sender, - "Observed lifeline…", - Event::Sender, - )); - } - if d.tool == Tool::Reuse - && let Some(target) = d.target - { - for line in lines(p, target) { - let referenced = line.id; - let compatible: Vec<_> = lines(p, owner) - .into_iter() - .filter(|c| { - compatible_binding(p, referenced, c.id) - && !d - .bindings - .iter() - .any(|(r, l)| *r != referenced && *l == c.id) - }) - .collect(); - if compatible.is_empty() { - form = form.push(label(format!( - "Add a compatible local lifeline before binding '{}'.", - p.named(referenced) - ))); - } - form = form - .push(label(format!("Bind {}", p.named(referenced)))) - .push(picker( - compatible, - d.bindings.get(&referenced).copied(), - "Compatible local lifeline…", - move |c| Event::Binding(referenced, c), + } else if d.style != MessageStyle::Timer { + form = form.push(bounded_picker( + exchanges(p, d), + d, + d.exchange, + "Choose the exchange…", + Event::Exchange, )); + form = form.push(label( + "Apply checks that the message is valid at this insertion position.", + )); + } + form = form.push(picker( + d.line_choices(p, true), + d.sender, + "Sending lifeline…", + Event::Sender, + )); + if d.style != MessageStyle::Timer { + form = form.push(picker( + d.line_choices(p, false), + d.receiver, + "Receiving lifeline…", + Event::Receiver, + )); + } + if d.style == MessageStyle::Timer { + form = form.push( + text_input("Duration / deadline, e.g. 30 s", &d.expression) + .on_input(|s| event(Event::Expression(s))) + .padding(9) + .size(12), + ); + } + if p.layer_of(owner) == Some(Layer::Sa) + && viewpoint(p, owner) == ScenarioViewpoint::BoundaryInterface + && matches!( + d.style, + MessageStyle::Call | MessageStyle::CallReply | MessageStyle::Asynchronous + ) + { + form = form.push( + iced::widget::checkbox(d.operation.is_some()) + .label("Invoke a specific interface operation") + .on_toggle(|enabled| event(Event::OperationIntent(enabled))), + ); + if let Some(operation) = &d.operation { + form = form + .push(bounded_picker( + crate::data_values::operation_choices(p, &d.proposed_message(p)), + d, + operation.allocation, + "Choose the exact interface operation allocation…", + Event::Operation, + )) + .push( + button("Keep operation unresolved") + .on_press(event(Event::ClearOperation)), + ); + if let Some(id) = operation.allocation { + form = form.push(label(crate::data_values::operation_identity(p, id))); + } + } + } + if let Some(description) = + crate::data_values::operation_label(p, &d.proposed_message(p)) + { + form = form.push(label(description)); + } + match d.signature(p) { + Ok(Some(exchange)) => form = form.push(value_panel(p, d, &exchange)), + Err(problem) => { + form = form.push(label(problem)); + for (key, expression) in d.authored_values() { + form = form + .push( + text(format!( + "Retained value · {} / {}", + key.path + .iter() + .map(|id| p.named(*id)) + .collect::>() + .join(" → "), + p.named(key.field) + )) + .size(11), + ) + .push( + text_input("Value expression", expression) + .on_input({ + let key = key.clone(); + move |raw| event(Event::Value(key.clone(), raw)) + }) + .padding(8) + .size(12), + ); + } + } + Ok(None) => {} + } + if d.style == MessageStyle::CallReply { + form=form.push(label("Creates the call and reversed, correlated reply together in one undoable operation.")); + } } - form = form.push(label( - "Bindings are explicit, one-to-one and refer to existing lifelines.", - )); - } - if d.tool == Tool::Fragment { - form = form.push( - pick_list( - vec![ - Operator(FragmentOperator::Alternative), - Operator(FragmentOperator::Parallel), - Operator(FragmentOperator::Loop), - Operator(FragmentOperator::Optional), - ], - Some(Operator(d.operator)), - |o| event(Event::Operator(o)), - ) - .padding(9) - .text_size(12) - .width(Fill), - ); - if d.operator != FragmentOperator::Parallel { + if matches!(d.tool, Tool::Action | Tool::Observation) { + form = form.push(picker( + lines(p, owner), + d.sender, + "Observed lifeline…", + Event::Sender, + )); + } + if d.tool == Tool::Reuse + && let Some(target) = d.target + { + for line in lines(p, target) { + let referenced = line.id; + let compatible: Vec<_> = lines(p, owner) + .into_iter() + .filter(|c| { + compatible_binding(p, referenced, c.id) + && !d + .bindings + .iter() + .any(|(r, l)| *r != referenced && *l == c.id) + }) + .collect(); + if compatible.is_empty() { + form = form.push(label(format!( + "Add a compatible local lifeline before binding '{}'.", + p.named(referenced) + ))); + } + form = form + .push(label(format!("Bind {}", p.named(referenced)))) + .push(picker( + compatible, + d.bindings.get(&referenced).copied(), + "Compatible local lifeline…", + move |c| Event::Binding(referenced, c), + )); + } + form = form.push(label( + "Bindings are explicit, one-to-one and refer to existing lifelines.", + )); + } + if d.tool == Tool::Fragment { form = form.push( - text_input("Condition / loop guard", &d.expression) - .on_input(|s| event(Event::Expression(s))) - .padding(9) - .size(12), + pick_list( + vec![ + Operator(FragmentOperator::Alternative), + Operator(FragmentOperator::Parallel), + Operator(FragmentOperator::Loop), + Operator(FragmentOperator::Optional), + ], + Some(Operator(d.operator)), + |o| event(Event::Operator(o)), + ) + .padding(9) + .text_size(12) + .width(Fill), ); + if d.operator != FragmentOperator::Parallel { + form = form.push( + text_input("Condition / loop guard", &d.expression) + .on_input(|s| event(Event::Expression(s))) + .padding(9) + .size(12), + ); + } + if d.operator == FragmentOperator::Alternative { + form = form.push( + text_input("Second alternative guard", &d.second_guard) + .on_input(|s| event(Event::SecondGuard(s))) + .padding(9) + .size(12), + ); + } + form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions.")); } - if d.operator == FragmentOperator::Alternative { - form = form.push( - text_input("Second alternative guard", &d.second_guard) - .on_input(|s| event(Event::SecondGuard(s))) - .padding(9) - .size(12), - ); + let readiness = d.command(p); + if let Err(error) = &readiness { + form = form.push(label(error.clone())); } - form=form.push(label("Creates the fragment with its operands. Choose an operand as destination when adding messages or actions.")); + let footer = row![ + button("Create") + .padding(9) + .on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))), + action("Cancel", event(Event::Cancel)) + ] + .spacing(7); + form_panel = editor_panel(footer.into(), form.into()); } - let readiness = d.command(p); - if let Err(error) = &readiness { - form = form.push(label(error.clone())); - } - let footer = row![ - button("Create") - .padding(9) - .on_press_maybe(readiness.is_ok().then_some(event(Event::Submit))), - action("Cancel", event(Event::Cancel)) - ] - .spacing(7); - form_panel = editor_panel(footer.into(), form.into()); } workspace_layout(header.into(), details, canvas, form_panel, pending) } diff --git a/crates/oa-desktop/src/behavior/source_draft.rs b/crates/oa-desktop/src/behavior/source_draft.rs new file mode 100644 index 00000000..a8a39041 --- /dev/null +++ b/crates/oa-desktop/src/behavior/source_draft.rs @@ -0,0 +1,1034 @@ +//! A prerequisite belongs to its pending consumer, never the accepted catalog. +use iced::{ + Element as UiElement, Fill, + widget::{button, column, container, row, scrollable, text, text_input}, +}; +use oa_core::*; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const NAME_INPUT: &str = "behavior-source-name"; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Scope { + Lifeline { + owner: Id, + viewpoint: ScenarioViewpoint, + }, + Work { + owner: Id, + }, + Machine { + layer: Layer, + }, +} +impl Scope { + fn owner(self) -> Option { + match self { + Self::Lifeline { owner, .. } | Self::Work { owner } => Some(owner), + Self::Machine { .. } => None, + } + } + fn layer(self, p: &Project) -> Result { + match self { + Self::Machine { layer } => Ok(layer), + _ => p + .layer_of(self.owner().unwrap()) + .ok_or("The consumer owner no longer exists.".into()), + } + } + fn kinds(self, layer: Layer) -> Vec { + match self { + Self::Work { .. } => vec![if layer == Layer::Oa { + Kind::Activity + } else { + Kind::Function + }], + Self::Lifeline { + viewpoint: ScenarioViewpoint::OperationalActivity, + .. + } => vec![Kind::Activity], + Self::Lifeline { + viewpoint: ScenarioViewpoint::Functional, + .. + } => vec![Kind::Function], + Self::Lifeline { + viewpoint: ScenarioViewpoint::ParticipantInteraction, + .. + } => vec![Kind::Entity, Kind::Actor, Kind::Role], + Self::Lifeline { + viewpoint: ScenarioViewpoint::BoundaryInterface, + .. + } => vec![Kind::System, Kind::SystemActor], + Self::Machine { .. } if layer == Layer::Oa => vec![Kind::Entity, Kind::Actor], + Self::Machine { .. } => vec![Kind::System, Kind::SystemActor], + _ => vec![], + } + } +} +#[derive(Debug, Clone)] +pub enum Event { + Open(Id), + Edit(Id, Edit), +} +#[derive(Debug, Clone)] +pub enum Edit { + Name(String), + Kind(Kind), + Parent(Option), + Search(String), + Page(usize), + Back, + Use, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Draft { + token: Id, + document: Id, + scope: Scope, + layer: Layer, + owner: Option, + id: Id, + kind: Option, + name: String, + parent: Option, + search: String, + page: usize, + identities: Vec, +} +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct State { + editing: Option, + staged: Option, + #[serde(default)] + engaged: bool, +} +impl State { + pub fn check_scope(&self, expected: Scope, selected: Option) -> Result<(), String> { + if self + .editing + .as_ref() + .or_else(|| self.staged.as_ref().filter(|d| Some(d.id) == selected)) + .is_some_and(|d| d.scope != expected) + { + return Err("The recovered source request belongs to a different consumer context. Go Back or cancel this draft.".into()); + } + Ok(()) + } + pub fn dismiss(&mut self, event: &Event) -> Result { + if let Event::Edit(token, Edit::Back) = event { + if self.editing.as_ref().is_none_or(|d| d.token != *token) { + return Err("This Back action belongs to a previous source form.".into()); + } + self.editing = None; + self.engaged = true; + return Ok(true); + } + Ok(false) + } + + pub fn engaged(&self) -> bool { + self.engaged || self.editing.is_some() || self.staged.is_some() + } + pub fn is_open(&self) -> bool { + self.editing.is_some() + } + pub fn back(&mut self) -> bool { + self.editing.take().is_some() + } + pub fn label(&self, selected: Option) -> Option { + self.staged + .as_ref() + .filter(|d| Some(d.id) == selected) + .map(|d| { + format!( + "New · {} · {}", + d.kind.map_or("Choose kind".into(), |k| k.to_string()), + d.name + ) + }) + } + pub fn update( + &mut self, + p: &Project, + scope: Scope, + invocation: Id, + event: Event, + ) -> Result, String> { + if self.dismiss(&event)? { + return Ok(None); + } + match event { + Event::Open(token) => { + if token != invocation || self.is_open() { + return Err("This source request is stale. Use the current form.".into()); + } + let layer = scope.layer(p)?; + let kinds = scope.kinds(layer); + if kinds.is_empty() { + return Err("Create new sources requires a strict scenario viewpoint; use an existing source here.".into()); + } + self.engaged = true; + self.editing = Some(Draft { + token: Id::new_v4(), + document: p.id, + scope, + layer, + owner: scope.owner().and_then(|id| p.elements.get(&id).cloned()), + id: Id::new_v4(), + kind: (kinds.len() == 1).then_some(kinds[0]), + name: String::new(), + parent: None, + search: String::new(), + page: 0, + identities: (0..match scope { + Scope::Work { owner } => p.children(owner).count() + 4, + Scope::Lifeline { .. } => 1, + Scope::Machine { .. } => 0, + }) + .map(|_| Id::new_v4()) + .collect(), + }); + Ok(None) + } + Event::Edit(token, edit) => { + let d = self + .editing + .as_mut() + .filter(|d| d.token == token && d.scope == scope && d.document == p.id) + .ok_or("This source response is stale; return to the current form.")?; + match edit { + Edit::Back => { + self.editing = None; + Ok(None) + } + Edit::Use => { + d.command(p)?; + let id = d.id; + self.staged = self.editing.take(); + Ok(Some(id)) + } + Edit::Name(name) => { + d.name = name; + Ok(None) + } + Edit::Kind(kind) => { + if !scope.kinds(d.layer).contains(&kind) { + return Err("Choose an explicitly supported source kind.".into()); + } + d.kind = Some(kind); + d.parent = None; + d.page = 0; + Ok(None) + } + Edit::Parent(parent) => { + let kind = d.kind.ok_or("Choose the source kind first.")?; + if !validation::can_parent(p, kind, d.layer, parent) { + return Err("Choose a valid semantic parent.".into()); + } + d.parent = parent.map(|id| p.elements[&id].clone()); + Ok(None) + } + Edit::Search(query) => { + d.search = query; + d.page = 0; + Ok(None) + } + Edit::Page(page) => { + d.page = page; + Ok(None) + } + } + } + } + } + pub fn preview(&self, p: &Project, selected: Option) -> Result, String> { + if self.is_open() { + return Err("Use this new source or go Back before creating the consumer.".into()); + } + self.staged + .as_ref() + .filter(|d| Some(d.id) == selected) + .map(|d| execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string())) + .transpose() + } + pub fn combine( + &self, + p: &Project, + selected: Option, + command: Command, + ) -> Result { + let Some(d) = self.staged.as_ref().filter(|d| Some(d.id) == selected) else { + return Ok(command); + }; + let preview = execute_batch(p, &[d.command(p)?]).map_err(|e| e.to_string())?; + let mut commands = match command { + Command::Batch(commands) => commands, + other => vec![other], + }; + let fresh: Vec<_> = commands + .iter() + .filter(|_| !matches!(d.scope, Scope::Machine { .. })) + .filter_map(|c| match c { + Command::CreateElement(e) => Some(e.id), + Command::CreateRelation(r) => Some(r.id), + _ => None, + }) + .collect(); + if fresh.len() > d.identities.len() { + return Err( + "The path grew beyond this draft. Return and create the source again.".into(), + ); + } + let replacements: BTreeMap<_, _> = fresh + .into_iter() + .zip(d.identities.iter().copied()) + .collect(); + if replacements.values().any(|id| preview.contains(*id)) { + return Err( + "A reserved consumer identity is already used. Start a fresh source draft.".into(), + ); + } + let remap = |id: &mut Id| { + if let Some(new) = replacements.get(id) { + *id = *new; + } + }; + for c in &mut commands { + match c { + Command::CreateElement(e) => { + remap(&mut e.id); + references::visit_element_refs(e, remap); + e.alias = preview.fresh_alias(&e.name, "contribution", e.id); + } + Command::CreateRelation(r) => { + remap(&mut r.id); + references::visit_relation_refs(r, remap); + r.alias = preview.fresh_alias(&r.name, "path", r.id); + } + Command::EditRelation(_, r) => references::visit_relation_refs(r, remap), + Command::SetSteps(_, steps) => steps.iter_mut().for_each(remap), + // Machine commands already carry their reserved identities. + Command::CreateDiagram(_) | Command::Layout(_) => {} + _ => return Err("Unsupported prerequisite consumer operation.".into()), + } + } + commands.insert(0, d.command(p)?); + let command = super::topology::unique_batch(p, commands); + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + Ok(command) + } + pub fn view<'a, R: iced::advanced::text::Renderer + 'a>( + &'a self, + p: &'a Project, + ) -> Option> { + let d = self.editing.as_ref()?; + let event = move |edit| Event::Edit(d.token, edit); + let kinds = d.scope.kinds(d.layer); + let determined = kinds.len() == 1 && d.kind == kinds.first().copied(); + let mut fields = column![ + text(if determined { + format!("New {} · {}", kinds[0], d.layer) + } else { + format!("New source · {}", d.layer) + }) + .size(14), + text_input("Source name (independent of the consumer name)", &d.name) + .id(NAME_INPUT) + .on_input(move |v| event(Edit::Name(v))) + .size(14) + .padding(6), + ] + .spacing(4); + if !determined { + let mut choices = row![].spacing(6); + for kind in kinds { + choices = choices.push( + container( + button(text(kind.to_string()).size(14)) + .padding(4) + .on_press(event(Edit::Kind(kind))) + .style(if d.kind == Some(kind) { + button::primary + } else { + button::secondary + }), + ) + .id(format!("behavior-source-kind-{kind:?}")), + ); + } + fields = fields.push(choices.wrap()); + } + fields = fields.push( + container( + text(format!( + "Semantic parent: {}", + d.parent + .as_ref() + .map_or(format!("{} layer root", d.layer), |e| format!( + "{} · {} · @{}", + e.name, + e.kind(), + e.alias + )) + )) + .size(12), + ) + .id("behavior-source-parent"), + ); + fields = fields + .push( + text(match d.scope { + Scope::Lifeline { owner, viewpoint } => { + format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint) + } + Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)), + Scope::Machine { .. } => "Owner for the pending state machine".into(), + }) + .size(12), + ) + .push( + text("The parent form is retained. This source is created only with its consumer.") + .size(12), + ); + if let Some(kind) = d.kind { + fields = fields.push(button("Use layer root").on_press_maybe( + validation::can_parent(p, kind, d.layer, None).then(|| event(Edit::Parent(None))), + )); + fields = fields.push( + text_input("Find a semantic parent (optional)", &d.search) + .on_input(move |v| event(Edit::Search(v))) + .padding(6), + ); + let query = d.search.to_lowercase(); + let mut parents: Vec<_> = p + .elements + .values() + .filter(|e| validation::can_parent(p, kind, d.layer, Some(e.id))) + .map(|e| (e.id, super::creation::reference_label(p, e.id))) + .filter(|(_, label)| label.to_lowercase().contains(&query)) + .collect(); + parents.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0))); + let page = d.page.min(parents.len().saturating_sub(1) / 6); + for (id, label) in parents.iter().skip(page * 6).take(6) { + fields = fields.push( + button(text(label.clone()).size(12)) + .on_press(event(Edit::Parent(Some(*id)))) + .width(Fill), + ); + } + if parents.len() > 6 { + fields = fields.push( + row![ + button("Previous").on_press_maybe( + (page > 0).then(|| event(Edit::Page(page.saturating_sub(1)))) + ), + text(format!("{} / {}", page + 1, parents.len().div_ceil(6))), + button("Next").on_press_maybe( + ((page + 1) * 6 < parents.len()).then(|| event(Edit::Page(page + 1))) + ) + ] + .spacing(6), + ); + } + } + let ready = d.command(p); + if let Err(error) = &ready { + fields = fields.push(text(error.clone()).size(12)); + } + Some( + container( + column![ + scrollable(fields).id("behavior-source-fields").height(Fill), + container( + row![ + button("Back · Esc").on_press(event(Edit::Back)), + button("Use this new source") + .on_press_maybe(ready.is_ok().then(|| event(Edit::Use))) + ] + .spacing(8) + ) + .id("behavior-source-actions") + ] + .spacing(10), + ) + .padding(12) + .height(Fill) + .width(Fill) + .into(), + ) + } +} +impl Draft { + fn command(&self, p: &Project) -> Result { + if self.document != p.id + || self.scope.layer(p)? != self.layer + || self + .owner + .as_ref() + .is_some_and(|e| p.elements.get(&e.id) != Some(e)) + { + return Err( + "The source request's document or consumer changed. Go Back and reopen Create new." + .into(), + ); + } + let kind = self + .kind + .filter(|kind| self.scope.kinds(self.layer).contains(kind)) + .ok_or("Choose the source kind explicitly.")?; + if self.name.trim().is_empty() { + return Err("Enter the source name.".into()); + } + if p.contains(self.id) { + return Err( + "The reserved source identity is already used. Go Back and start again.".into(), + ); + } + let parent = self.parent.as_ref().map(|e| e.id); + if self + .parent + .as_ref() + .is_some_and(|e| p.elements.get(&e.id) != Some(e)) + || !validation::can_parent(p, kind, self.layer, parent) + { + return Err("The semantic parent changed. Choose its intended parent again.".into()); + } + let e = Element { + id: self.id, + alias: p.fresh_alias(&self.name, "source", self.id), + name: self.name.clone(), + description: String::new(), + parent, + body: ElementBody::new(kind, self.layer) + .ok_or("This source kind is unavailable in this layer.")?, + }; + let command = Command::CreateElement(e); + execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?; + Ok(command) + } +} + +#[cfg(all(test, debug_assertions))] +mod layout_tests { + use super::*; + use iced::advanced::{Layout, Shell, graphics, layout, renderer, text as shaped, widget}; + use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector}; + + // Use Iced's real paragraph shaping/measurement without a window or GPU. + // The unit renderer's zero-size paragraphs cannot detect this regression. + struct FontLayout; + impl renderer::Renderer for FontLayout { + fn start_layer(&mut self, _: Rectangle) {} + fn end_layer(&mut self) {} + fn start_transformation(&mut self, _: Transformation) {} + fn end_transformation(&mut self) {} + fn reset(&mut self, _: Rectangle) {} + fn fill_quad(&mut self, _: renderer::Quad, _: impl Into) {} + fn allocate_image( + &mut self, + handle: &iced::advanced::image::Handle, + callback: impl FnOnce( + Result, + ) + Send + + 'static, + ) { + renderer::Renderer::allocate_image(&mut (), handle, callback); + } + } + impl shaped::Renderer for FontLayout { + type Font = Font; + type Paragraph = graphics::text::Paragraph; + type Editor = (); + const ICON_FONT: Font = Font::DEFAULT; + const CHECKMARK_ICON: char = '0'; + const ARROW_DOWN_ICON: char = '0'; + const SCROLL_UP_ICON: char = '0'; + const SCROLL_DOWN_ICON: char = '0'; + const SCROLL_LEFT_ICON: char = '0'; + const SCROLL_RIGHT_ICON: char = '0'; + const ICED_LOGO: char = '0'; + fn default_font(&self) -> Font { + Font::DEFAULT + } + fn default_size(&self) -> Pixels { + Pixels(16.) + } + fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {} + fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {} + fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {} + } + #[derive(Default)] + struct Geometry { + fields: Option, + content: Option, + name: Option, + parent: Option, + actions: Option, + kinds: BTreeMap, + text: Vec<(String, Rectangle)>, + focus_name: bool, + } + impl widget::Operation for Geometry { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) { + visit(self); + } + fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) { + if id == Some(&widget::Id::new("behavior-source-actions")) { + self.actions = Some(bounds); + } + if id == Some(&widget::Id::new("behavior-source-parent")) { + self.parent = Some(bounds); + } + for kind in [ + Kind::Entity, + Kind::Actor, + Kind::Role, + Kind::System, + Kind::SystemActor, + Kind::Activity, + Kind::Function, + ] { + if id == Some(&widget::Id::from(format!("behavior-source-kind-{kind:?}"))) { + self.kinds.insert(kind.to_string(), bounds); + } + } + } + fn scrollable( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn widget::operation::Scrollable, + ) { + if id == Some(&widget::Id::new("behavior-source-fields")) { + assert_eq!( + offset, + Vector::ZERO, + "Initial fields must be visible without scrolling" + ); + self.fields = Some(bounds); + self.content = Some(content); + } + } + fn text_input( + &mut self, + id: Option<&widget::Id>, + bounds: Rectangle, + _: &mut dyn widget::operation::TextInput, + ) { + if id == Some(&widget::Id::new(NAME_INPUT)) { + self.name = Some(bounds); + } + } + fn focusable( + &mut self, + id: Option<&widget::Id>, + _: Rectangle, + state: &mut dyn widget::operation::Focusable, + ) { + if self.focus_name && id == Some(&widget::Id::new(NAME_INPUT)) { + state.focus(); + } + } + fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) { + self.text.push((text.into(), bounds)); + } + } + fn contains(outer: Rectangle, inner: Rectangle) -> bool { + inner.width > 0. + && inner.height > 0. + && inner.x >= outer.x - 0.1 + && inner.y >= outer.y - 0.1 + && inner.x + inner.width <= outer.x + outer.width + 0.1 + && inner.y + inner.height <= outer.y + outer.height + 0.1 + } + fn host<'a>( + state: &'a State, + p: &'a Project, + caption: &str, + ) -> UiElement<'a, Event, iced::Theme, FontLayout> { + iced::widget::column![text(caption.to_owned()).size(12), state.view(p).unwrap()] + .spacing(6) + .height(Fill) + .width(Fill) + .into() + } + fn geometry( + view: &mut UiElement<'_, Event, iced::Theme, FontLayout>, + tree: &mut widget::Tree, + size: Size, + ) -> (layout::Node, Geometry) { + let node = + view.as_widget_mut() + .layout(tree, &FontLayout, &layout::Limits::new(Size::ZERO, size)); + let mut geometry = Geometry::default(); + view.as_widget_mut() + .operate(tree, Layout::new(&node), &FontLayout, &mut geometry); + (node, geometry) + } + fn fixture(layer: Layer, viewpoint: Option, work: bool) -> (Project, State) { + let mut p = Project::empty(); + let scope = if work { + let owner = p + .add_element( + if layer == Layer::Oa { + Kind::Process + } else { + Kind::FunctionalChain + }, + layer, + "Consumer", + None, + ) + .unwrap(); + Scope::Work { owner } + } else if let Some(viewpoint) = viewpoint { + let owner = p + .add_element( + Kind::Scenario, + layer, + "Incident coordination with a long contextual heading 雨 Ω", + None, + ) + .unwrap(); + if let Some(SharedBody::Scenario { viewpoint: v, .. }) = + p.elements.get_mut(&owner).unwrap().shared_mut() + { + *v = viewpoint; + } + Scope::Lifeline { owner, viewpoint } + } else { + Scope::Machine { layer } + }; + let mut state = State::default(); + let invocation = Id::new_v4(); + state + .update(&p, scope, invocation, Event::Open(invocation)) + .unwrap(); + (p, state) + } + #[test] + fn contextual_source_layout_initial_essentials_fit_short_composed_host_with_real_paragraphs() { + for (layer, viewpoint, work) in [ + ( + Layer::Oa, + Some(ScenarioViewpoint::OperationalActivity), + false, + ), + (Layer::Sa, Some(ScenarioViewpoint::Functional), false), + ( + Layer::Oa, + Some(ScenarioViewpoint::ParticipantInteraction), + false, + ), + (Layer::Sa, Some(ScenarioViewpoint::BoundaryInterface), false), + (Layer::Oa, None, false), + (Layer::Sa, None, false), + (Layer::Oa, None, true), + (Layer::Sa, None, true), + ] { + let (p, state) = fixture(layer, viewpoint, work); + let raw = serde_json::to_value(&state).unwrap(); + for width in [340., 500.] { + // The retained caption + 6px gap leaves approximately the observed 194px card. + let size = Size::new(width, 215.); + let mut view = host(&state, &p, "Consumer name: Assessment lifeline"); + let mut tree = widget::Tree::new(view.as_widget()); + let (_, g) = geometry(&mut view, &mut tree, size); + let fields = g.fields.unwrap(); + assert!( + (125. ..=132.).contains(&fields.height), + "Measured field viewport: {fields:?}" + ); + assert!( + contains(fields, g.name.unwrap()), + "Name clipped: {layer:?}/{viewpoint:?}/{work}/{width}" + ); + assert!( + contains(fields, g.parent.unwrap()), + "Parent clipped: {layer:?}/{viewpoint:?}/{work}/{width}" + ); + assert!(contains( + Rectangle::new(Point::ORIGIN, size), + g.actions.unwrap() + )); + assert!(fields.y + fields.height <= g.actions.unwrap().y); + let expected = state.editing.as_ref().unwrap().scope.kinds(layer); + assert_eq!( + g.kinds.len(), + if expected.len() == 1 { + 0 + } else { + expected.len() + } + ); + for bounds in g.kinds.values() { + assert!( + contains(fields, *bounds), + "Kind clipped: {bounds:?} in {fields:?}" + ); + } + let parent_text = g + .text + .iter() + .find(|(s, _)| s.starts_with("Semantic parent:")) + .unwrap(); + assert_eq!( + parent_text.0, + format!("Semantic parent: {layer} layer root") + ); + assert!( + parent_text.1.height > 0., + "Paragraph measurement must not be null" + ); + let explanation = g + .text + .iter() + .find(|(s, _)| s.starts_with("The parent form")) + .unwrap(); + assert!(explanation.1.y >= g.parent.unwrap().y + g.parent.unwrap().height); + } + assert_eq!( + serde_json::to_value(&state).unwrap(), + raw, + "Layout must not choose a kind or mutate the draft" + ); + if state.editing.as_ref().unwrap().scope.kinds(layer).len() == 1 { + let mut incomplete = state.clone(); + incomplete.editing.as_mut().unwrap().kind = None; + let mut view = host(&incomplete, &p, "Consumer name: Assessment lifeline"); + let mut tree = widget::Tree::new(view.as_widget()); + let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.)); + assert_eq!( + g.kinds.len(), + 1, + "An incomplete recovery still needs an explicit repair choice" + ); + assert!(contains( + g.fields.unwrap(), + *g.kinds.values().next().unwrap() + )); + assert!(contains(g.fields.unwrap(), g.name.unwrap())); + assert_eq!(incomplete.editing.as_ref().unwrap().kind, None); + } + } + } + #[test] + fn contextual_source_layout_long_parent_wraps_after_visible_name_without_losing_raw_data() { + let (mut p, mut state) = fixture(Layer::Oa, None, true); + let parent = p + .add_element( + Kind::Activity, + Layer::Oa, + "Northern operations coordination — exact parent 雨", + None, + ) + .unwrap(); + let d = state.editing.as_ref().unwrap(); + let token = d.token; + let scope = d.scope; + let invocation = Id::new_v4(); + state + .update( + &p, + scope, + invocation, + Event::Edit(token, Edit::Parent(Some(parent))), + ) + .unwrap(); + state + .update( + &p, + scope, + invocation, + Event::Edit(token, Edit::Name(" Distinct source 雨 ".into())), + ) + .unwrap(); + let raw = serde_json::to_value(&state).unwrap(); + for width in [340., 500.] { + let size = Size::new(width, 235.); + let mut view = host( + &state, + &p, + "Consumer name: Coordination with a long independent label 雨 Ω", + ); + let mut tree = widget::Tree::new(view.as_widget()); + let (_, g) = geometry(&mut view, &mut tree, size); + assert!(contains(g.fields.unwrap(), g.name.unwrap())); + assert!(contains(g.fields.unwrap(), g.parent.unwrap())); + assert!(contains( + Rectangle::new(Point::ORIGIN, size), + g.actions.unwrap() + )); + let summary = g + .text + .iter() + .find(|(s, _)| s.starts_with("Semantic parent:")) + .unwrap(); + assert!(summary.0.contains(&p.elements[&parent].name)); + assert!(summary.0.contains(&p.elements[&parent].alias)); + assert!( + summary.1.height > 15., + "Long parent should exercise real wrapping" + ); + } + assert_eq!(serde_json::to_value(&state).unwrap(), raw); + assert_eq!( + state.editing.as_ref().unwrap().name, + " Distinct source 雨 " + ); + + let (mut p, mut state) = fixture( + Layer::Oa, + Some(ScenarioViewpoint::ParticipantInteraction), + false, + ); + let parent = p + .add_element( + Kind::Entity, + Layer::Oa, + // Eight repetitions need not exceed a whole viewport with the + // platform's measured font. Exercise substantial real overflow. + &"Northern operational responsibility 雨 ".repeat(32), + None, + ) + .unwrap(); + let d = state.editing.as_ref().unwrap(); + let token = d.token; + let scope = d.scope; + state + .update( + &p, + scope, + Id::new_v4(), + Event::Edit(token, Edit::Kind(Kind::Actor)), + ) + .unwrap(); + state + .update( + &p, + scope, + Id::new_v4(), + Event::Edit(token, Edit::Parent(Some(parent))), + ) + .unwrap(); + let raw = serde_json::to_value(&state).unwrap(); + let recovered: State = serde_json::from_value(raw.clone()).unwrap(); + let mut view = host(&recovered, &p, "Consumer name: Assessment lifeline"); + let mut tree = widget::Tree::new(view.as_widget()); + let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.)); + let fields = g.fields.unwrap(); + assert!(contains(fields, g.name.unwrap())); + assert_eq!(g.kinds.len(), 3); + for bounds in g.kinds.values() { + assert!( + contains(fields, *bounds), + "Long parent must not hide a semantic choice" + ); + assert!(bounds.y + bounds.height <= g.parent.unwrap().y); + } + // Arbitrary authored detail may scroll, but its label and first line are + // visible after the required kind controls, never in front of them. + let parent_bounds = g.parent.unwrap(); + assert!(parent_bounds.y + 16. <= fields.y + fields.height); + assert!( + parent_bounds.height > fields.height, + "Exercise genuinely overflowing parent text: {parent_bounds:?} in {fields:?}" + ); + let content = g.content.unwrap(); + assert!( + content.height > fields.height && contains(content, parent_bounds), + "Full parent must remain in scrollable content: {parent_bounds:?}, {content:?}, {fields:?}" + ); + let summary = g + .text + .iter() + .find(|(s, _)| s.starts_with("Semantic parent:")) + .unwrap(); + assert!(summary.0.contains(&p.elements[&parent].name)); + assert!(summary.0.contains(&p.elements[&parent].alias)); + assert!(contains( + Rectangle::new(Point::ORIGIN, Size::new(340., 215.)), + g.actions.unwrap() + )); + assert_eq!(serde_json::to_value(&recovered).unwrap(), raw); + } + #[test] + fn contextual_source_layout_visible_kind_click_keeps_choice_explicit_and_name_input_works() { + let (p, mut state) = fixture( + Layer::Oa, + Some(ScenarioViewpoint::ParticipantInteraction), + false, + ); + let original = p.clone(); + let token = state.editing.as_ref().unwrap().token; + let scope = state.editing.as_ref().unwrap().scope; + assert_eq!(state.editing.as_ref().unwrap().kind, None); + let size = Size::new(340., 215.); + let mut view = host(&state, &p, "Consumer name: Assessment lifeline"); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, g) = geometry(&mut view, &mut tree, size); + let mut messages = Vec::new(); + for kind in [Kind::Entity, Kind::Actor, Kind::Role] { + let bounds = g.kinds[&kind.to_string()]; + assert!(contains(g.fields.unwrap(), bounds)); + for action in [ + iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left), + iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left), + ] { + view.as_widget_mut().update( + &mut tree, + &iced::Event::Mouse(action), + Layout::new(&node), + iced::mouse::Cursor::Available(bounds.center()), + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut Shell::new(&mut messages), + &Rectangle::new(Point::ORIGIN, size), + ); + } + assert!( + matches!(messages.pop(), Some(Event::Edit(id, Edit::Kind(k))) if id == token && k == kind) + ); + } + let mut focus = Geometry { + focus_name: true, + ..Geometry::default() + }; + view.as_widget_mut() + .operate(&mut tree, Layout::new(&node), &FontLayout, &mut focus); + let event = iced::Event::Keyboard(iced::keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Character("Ω".into()), + modified_key: iced::keyboard::Key::Character("Ω".into()), + physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::KeyA), + location: iced::keyboard::Location::Standard, + modifiers: iced::keyboard::Modifiers::empty(), + text: Some("Ω".into()), + repeat: false, + }); + view.as_widget_mut().update( + &mut tree, + &event, + Layout::new(&node), + iced::mouse::Cursor::Unavailable, + &FontLayout, + &mut iced::advanced::clipboard::Null, + &mut Shell::new(&mut messages), + &Rectangle::new(Point::ORIGIN, size), + ); + let edit = messages.pop().unwrap(); + assert!(matches!(&edit, Event::Edit(id, Edit::Name(name)) if *id == token && name == "Ω")); + drop(view); + state.update(&p, scope, Id::new_v4(), edit).unwrap(); + assert_eq!( + state.editing.as_ref().unwrap().kind, + None, + "Rendering/click event production must not silently adopt a kind" + ); + assert_eq!(state.editing.as_ref().unwrap().name, "Ω"); + assert_eq!(p, original); + } +} diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index 26f813da..d23c40f2 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -38,6 +38,7 @@ impl Tool { } #[derive(Debug, Clone)] pub enum Event { + Source(super::source_draft::Event), Start(Id, Tool, Option), Cancel, Submit, @@ -90,15 +91,22 @@ pub struct State { selected_context: Option, shelf: Option, } -#[derive(Default, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Default, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct Browser { search: String, page: usize, } -#[derive(serde::Serialize, serde::Deserialize)] +#[derive(Clone, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] struct Draft { + #[serde(default)] + source: super::source_draft::State, + #[serde(default = "Id::new_v4")] + invocation: Id, + #[serde(default)] + source_origin: Option<(Id, oa_core::Element)>, + #[serde(with = "recovery_insertion")] insertion: Insertion, fixed: bool, @@ -117,6 +125,28 @@ struct Draft { contextual: Option, } impl State { + pub(super) fn pending_name(&self) -> Option<&str> { + self.draft.as_ref().map(|d| d.name.as_str()) + } + pub(super) fn source_view<'a>(&'a self, p: &'a Project) -> Option> { + self.draft + .as_ref()? + .source + .view(p) + .map(|v| v.map(Event::Source)) + } + + pub fn source_active(&self) -> bool { + self.draft.as_ref().is_some_and(|d| d.source.engaged()) + } + + pub fn source_open(&self) -> bool { + self.draft.as_ref().is_some_and(|d| d.source.is_open()) + } + pub fn source_back(&mut self) -> bool { + self.draft.as_mut().is_some_and(|d| d.source.back()) + } + pub(super) fn recovery_snapshot(&self) -> Result, String> { self.draft .as_ref() @@ -152,6 +182,58 @@ impl State { } pub fn update(&mut self, p: &Project, event: Event) -> Result, String> { + if let Event::Source(event) = event { + if self + .draft + .as_mut() + .ok_or("Start the consumer form first.")? + .source + .dismiss(&event)? + { + return Ok(None); + } + let d = self + .draft + .as_ref() + .ok_or("Start the consumer form first.")?; + if d.tool != Tool::Work { + return Err("This reference does not support staged source creation.".into()); + } + if d.source_origin.as_ref().is_some_and(|(document, owner)| { + *document != p.id || p.elements.get(&d.owner) != Some(owner) + }) { + return Err("The original consumer changed. Cancel and reopen its form; the current draft is retained.".into()); + } + if d.source_origin.is_none() && d.source.engaged() { + return Err("The recovered source draft has no original document binding. Go Back or cancel it.".into()); + } + let mut next = d.clone(); + if next.source_origin.is_none() && matches!(&event, super::source_draft::Event::Open(_)) + { + next.source_origin = Some(( + p.id, + p.elements + .get(&d.owner) + .ok_or("The consumer owner no longer exists.")? + .clone(), + )); + } + if let Some(id) = next.source.update( + p, + super::source_draft::Scope::Work { owner: d.owner }, + d.invocation, + event, + )? { + next.target = Some(id); + next.command(p)?; + } + self.draft = Some(next); + return Ok(None); + } + if self.source_open() && !matches!(&event, Event::Cancel) { + return Err("Use the new source or go Back before editing the parent form.".into()); + } + if self.draft.is_some() && matches!(&event, Event::Start(..)) { return Err(PENDING_DRAFT.into()); } @@ -184,6 +266,9 @@ impl State { } }); self.draft = Some(Draft { + source: Default::default(), + invocation: Id::new_v4(), + source_origin: Some((p.id, p.elements[&owner].clone())), insertion: Insertion::default(), fixed: false, browsers: Default::default(), @@ -508,6 +593,22 @@ fn link(p: &Project, source: Id, target: Id, guard: &str) -> Command { } impl Draft { fn command(&self, p: &Project) -> Result { + if self.source.engaged() + && self.source_origin.as_ref().is_none_or(|(document, owner)| { + *document != p.id || p.elements.get(&self.owner) != Some(owner) + }) + { + return Err("The original consumer document or owner changed. Cancel and reopen its form; your draft is retained.".into()); + } + self.source.check_scope( + super::source_draft::Scope::Work { owner: self.owner }, + self.target, + )?; + let preview = self.source.preview(p, self.target)?; + let command = self.consumer_command(preview.as_ref().unwrap_or(p))?; + self.source.combine(p, self.target, command) + } + fn consumer_command(&self, p: &Project) -> Result { let owner = p .elements .get(&self.owner) @@ -615,6 +716,8 @@ impl Draft { }); let name = if self.name.trim().is_empty() { default_name + } else if self.source.label(self.target).is_some() { + self.name.as_str() } else { self.name.trim() }; @@ -1587,6 +1690,9 @@ mod tests { } fn branch(owner: Id, a: Id, b: Id, before: Id, after: Id) -> Draft { Draft { + source: Default::default(), + invocation: Id::new_v4(), + source_origin: None, insertion: Insertion::default(), fixed: false, browsers: Default::default(), diff --git a/crates/oa-desktop/src/behavior/topology/contextual.rs b/crates/oa-desktop/src/behavior/topology/contextual.rs index ab498e8d..d7396f31 100644 --- a/crates/oa-desktop/src/behavior/topology/contextual.rs +++ b/crates/oa-desktop/src/behavior/topology/contextual.rs @@ -33,7 +33,7 @@ pub enum Event { Search(String), Page(usize), } -#[derive(Default, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Default, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct Draft { edit: Option, diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 88b99788..88c6e3f7 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -206,8 +206,16 @@ impl State { } None } - pub(crate) fn created_selection(&self, command: &Command) -> Option { + pub(crate) fn created_selection(&self, p: &Project, command: &Command) -> Option { let owner = self.pending_owner()?; + if self + .draft + .as_ref() + .is_some_and(|d| d.source.label(d.target).is_some()) + && self.prepare_pending(p).ok().as_ref() != Some(command) + { + return None; + } if let Command::CreateRelation(r) | Command::EditRelation(_, r) = command && matches!(&r.body, RelationBody::ContextualLink { owner: caller, .. } if *caller == owner) { diff --git a/crates/oa-desktop/src/behavior/topology/view.rs b/crates/oa-desktop/src/behavior/topology/view.rs index 0db870b7..1279881d 100644 --- a/crates/oa-desktop/src/behavior/topology/view.rs +++ b/crates/oa-desktop/src/behavior/topology/view.rs @@ -369,6 +369,25 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele .width(Fill) .height(Fill) .style(style::card); + if !pending && !p.children(owner).any(|e| is_contribution(p, owner, e.id)) { + let title = if p.layer_of(owner) == Some(Layer::Oa) { + "Add first activity contribution" + } else { + "Add first function contribution" + }; + header = header.push(button(title).on_press_maybe( + (!app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty()).then( + || { + Message::TopologyCanvas(CanvasEvent::Begin { + context, + tool: Tool::Work, + control: ControlKind::Choice, + insertion: Insertion::default(), + }) + }, + ), + )); + } let details: Element<'_, Message> = match state.shelf { Some(Shelf::Tools) => controls.into(), _ => navigation.push(text("Full qualified identities and definition actions appear here for the selected contribution or link.").size(11)).into(), @@ -383,201 +402,223 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele }; let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into(); 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(contextual) = &d.contextual { - form = form - .push( - text_input("Link name (retain current when blank)", &d.name) - .on_input(|v| event(Event::Name(v))) - .padding(8) - .size(12), - ) - .push(super::contextual::view(p, owner, contextual)); + if let Some(child) = d.source.view(p) { + form_panel = column![ + text(format!( + "Consumer name: {}", + crate::views::short(&d.name, 60) + )) + .size(12), + child.map(|e| event(Event::Source(e))) + ] + .spacing(6) + .height(Fill) + .into(); } else { - 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 && ordered { - form = form.push(label(match (d.before, d.after) { - (Some(before), Some(after)) => format!( - "Insert between {} → {} · preserve the ordered path", - p.named(before), - p.named(after) - ), - (Some(before), None) => format!("Continue after {}", p.named(before)), - (None, Some(after)) => format!("Insert before {}", p.named(after)), - _ => "Start the ordered path".into(), - })); - } 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( + let mut form = column![text(d.tool.title()).size(16)].spacing(8); + if let Some(contextual) = &d.contextual { + form = form + .push( + text_input("Link name (retain current when blank)", &d.name) + .on_input(|v| event(Event::Name(v))) + .padding(8) + .size(12), + ) + .push(super::contextual::view(p, owner, contextual)); + } else { + 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 && ordered { + form = form.push(label(match (d.before, d.after) { + (Some(before), Some(after)) => format!( + "Insert between {} → {} · preserve the ordered path", + p.named(before), + p.named(after) + ), + (Some(before), None) => format!("Continue after {}", p.named(before)), + (None, Some(after)) => format!("Insert before {}", p.named(after)), + _ => "Start the ordered path".into(), + })); + } 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 d.tool == Tool::Work { + if let Some(title) = d.source.label(d.target) { + form = form.push(label(title)).push(label("Create commits the new work and contribution together at the chosen path position.")); + } + form = form.push(button("Create new source…").on_press(event(Event::Source( + super::super::source_draft::Event::Open(d.invocation), + )))); + } + 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(); - if d.tool == Tool::Branch { - form = form.push(label("First path work")); } - form = form.push(picker( - choices, - d, - 0, - d.target, + 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(); if d.tool == Tool::Branch { - "Choose first path work…" + form = form.push(label("First path work")); + } + form = form.push(picker( + choices, + d, + 0, + d.target, + if d.tool == Tool::Branch { + "Choose first path work…" + } else { + "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 { - "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))) + vec![ + ControlKind::Choice, + ControlKind::Merge, + ControlKind::Fork, + ControlKind::Join, + ControlKind::Loop, + ] + }; + form = form.push( + pick_list( + kinds.into_iter().map(ControlChoice).collect::>(), + Some(ControlChoice(d.control)), + |v| event(Event::Control(v)), + ) + .padding(8) + .text_size(12), + ); + } + if d.tool == Tool::Branch { + form = form.push(label("Second path work")).push(picker( + targets(p, owner, Tool::Work), + d, + 1, + d.alternate, + "Second path work…", + Event::Alternate, + )); + if d.control != ControlKind::Fork { + form = form + .push(label("First path guard")) + .push( + text_input("First path guard", &d.guard) + .on_input(|v| event(Event::Guard(v))) + .padding(8), + ) + .push(label("Second path guard")) + .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(label("Preceding contribution")) + .push(picker( + contributions(p, owner), + d, + 2, + d.before, + "After contribution…", + Event::Before, + )) + .push(button("Clear preceding").on_press(event(Event::ClearBefore))) + .push(label("Following contribution")) + .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), - ); + ); + } } - if matches!(d.tool, Tool::Control | Tool::Branch) { - let kinds = if d.tool == Tool::Branch { - vec![ControlKind::Choice, ControlKind::Fork] + let valid = match d.command(p) { + Ok(_) => true, + Err(problem) => { + form = form.push(label(problem)); + false + } + }; + let actions = row![ + button(if d.contextual.is_some() { + "Apply link" } else { - vec![ - ControlKind::Choice, - ControlKind::Merge, - ControlKind::Fork, - ControlKind::Join, - ControlKind::Loop, - ] - }; - form = form.push( - pick_list( - kinds.into_iter().map(ControlChoice).collect::>(), - Some(ControlChoice(d.control)), - |v| event(Event::Control(v)), - ) - .padding(8) - .text_size(12), - ); - } - if d.tool == Tool::Branch { - form = form.push(label("Second path work")).push(picker( - targets(p, owner, Tool::Work), - d, - 1, - d.alternate, - "Second path work…", - Event::Alternate, - )); - if d.control != ControlKind::Fork { - form = form - .push(label("First path guard")) - .push( - text_input("First path guard", &d.guard) - .on_input(|v| event(Event::Guard(v))) - .padding(8), - ) - .push(label("Second path guard")) - .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(label("Preceding contribution")) - .push(picker( - contributions(p, owner), - d, - 2, - d.before, - "After contribution…", - Event::Before, - )) - .push(button("Clear preceding").on_press(event(Event::ClearBefore))) - .push(label("Following contribution")) - .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), - ); - } + "Create" + }) + .on_press_maybe(valid.then(|| event(Event::Submit))), + button("Cancel").on_press(event(Event::Cancel)) + ] + .spacing(7); + form_panel = editor_panel(actions.into(), form.into()); } - let valid = match d.command(p) { - Ok(_) => true, - Err(problem) => { - form = form.push(label(problem)); - false - } - }; - let actions = row![ - button(if d.contextual.is_some() { - "Apply link" - } else { - "Create" - }) - .on_press_maybe(valid.then(|| event(Event::Submit))), - button("Cancel").on_press(event(Event::Cancel)) - ] - .spacing(7); - form_panel = editor_panel(actions.into(), form.into()); } panes(diagram, form_panel, pending) } diff --git a/crates/oa-desktop/src/guide.rs b/crates/oa-desktop/src/guide.rs index 9fc59025..1d72185a 100644 --- a/crates/oa-desktop/src/guide.rs +++ b/crates/oa-desktop/src/guide.rs @@ -445,9 +445,9 @@ fn article(topic: Topic) -> Article { walkthrough( "Describe a path through exchanges", &[ - "First create the activities/functions and their exchanges. Check the source and target work for every exchange.", + "A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.", "Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.", - "Open Tools and choose Work or Exchange, then a highlighted path or contribution position to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.", + "Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.", "In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.", "Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.", "Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.", @@ -490,7 +490,7 @@ fn article(topic: Topic) -> Article { "Create a scenario", &[ "Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.", - "Use + Lifeline and choose the participant or work it represents. Choices follow the scenario's viewpoint: operational activities, operational participants, system functions or system boundary participants.", + "Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.", "Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.", "Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.", "Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.", @@ -551,7 +551,8 @@ fn article(topic: Topic) -> Article { walkthrough( "Build a state machine", &[ - "In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names. If the participant does not exist yet, cancel and create it in Architecture first.", + "In Behavior & scenarios → New → State machine, enter its name and search for the existing same-layer participant that owns it. Candidate labels show parent context and alias to distinguish repeated names.", + "If the owner does not exist, Create new owner retains the machine name and first-view option. Choose its participant kind and semantic parent explicitly. Use this new source returns without changing the model; Create and open commits owner, machine and optional view together.", "Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.", "Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.", "Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.", @@ -909,7 +910,7 @@ fn article(topic: Topic) -> Article { ), explain( "A new behavior is still a draft", - "Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.", + "Inside a source subform, Back or the first Escape returns to the unchanged parent. Use this new source stages the prerequisite only. Canceling the parent discards it; choosing an existing reference excludes the staged source from creation. Recovery retains both forms and their reserved identities. An open dialog still receives Escape first. Behavior → New keeps the exact name, owner and viewpoint choices outside the model until Create and open succeeds. Cancel or Escape discards only this draft; if a dialog is open, the first Escape dismisses that dialog. New refuses other unfinished property, source or behavior drafts without applying them. Recovery restores the creation form in its original layer, including rejected raw text, for correction or cancellation.", ), explain( "Aliases and references", diff --git a/harness/STATE.md b/harness/STATE.md index c88e9d0d..0d2d4a89 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -1,6 +1,6 @@ # Integrated checkpoint — 2026-09-11 -Codex-ytr83r, branch `codex/release1-ytr83r`. The full [user goal](../PROJECT_GOAL.md) remains incomplete. Live priorities, ownership and handoffs are in [Gitea issue 1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-410) and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2). This file scopes integrated code and evidence. +Codex-ytr83r, branch `codex/release1-ytr83r`. The full [user goal](../PROJECT_GOAL.md) remains incomplete. Live integration/acceptance work is in [Gitea issue5](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5); the [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) and [process/scenario requirement](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2) link other work. This file scopes integrated code and evidence. ## Verified code and capabilities @@ -13,6 +13,8 @@ B4 renders local lifetimes, branch-local activation termination, neutral incompl - [Lifecycle implementation and independent findings](release-lifecycle-render-ytr83r-20260911.md). - [Creation and source integration evidence](release-staged-source-integration-ytr83r-20260911.md). +The current candidate also integrates the published contextual source-creation/minimum-card corrections from b2c3e540, preserving the complete local B4 Scene implementation. Its own desktop/native acceptance is pending. [Exact selective integration and independent union review](release-contextual-integration-update-ytr83r-20260911.md). + ## Verification scope On this Windows host, core all-targets passed433 tests with one intentionally ignored scaling probe; that probe separately passed with unoptimized timings. Production29084050 passed focused lifecycle22 and full desktop675 tests (the22 are included in675), and the ordinary GUI build/launch. An equivalent test-helper let-chain correction subsequently passed strict workspace/all-targets Clippy and its focused regression1/1. These results predate the newly identified header-rendering correction. @@ -24,7 +26,7 @@ On this Windows host, core all-targets passed433 tests with one intentionally ig ## Material limitations -Native wide Latin/CJK checks found neutral lifeline header text overflowing its card; Create labels remained contained. Corrected production/native acceptance is not yet established. Combining/emoji native appearance and passive-save equality for the typography variants remain unverified. Current-host server/CLI transport/restart and matching optimized packages are not accepted by the desktop fixture results. +Native wide Latin/CJK checks found neutral lifeline header text overflowing its card; Create labels remained contained. Corrected production/native acceptance is not yet established. Combining/emoji native appearance remains unverified. The wide Latin/CJK variant’s passive Save passed complete independent identity/model/source/file equality on the pre-correction GUI. Current-host server+CLI all-targets subsequently passed68 tests across7 targets, and the semantic schema checker passed72 fixtures/204 schemas. Matching standalone transport/package checks remain separate from these results. Coherent contextual source/chooser/canvas contributions from other branches require integration and verification here. Calculations, quantities/units, maintained libraries, practical large-model textual workflows, complete OA↔SA engineering parity, documentation and fresh cross-disciplinary review remain full-goal requirements. See the [analysis proposal](release-analysis-semantics-design-20260910.md), [library proposal](release-libraries-semantics-design-20260910.md), [text-authoring design](release-textual-authoring-design-20260911.md) and [decisions](DECISIONS.md) for attributed design evidence, not completed implementation claims. diff --git a/harness/fixtures/staged-source-authoring/README.md b/harness/fixtures/staged-source-authoring/README.md new file mode 100644 index 00000000..7af78a5c --- /dev/null +++ b/harness/fixtures/staged-source-authoring/README.md @@ -0,0 +1,52 @@ +# Staged prerequisite source creation: frozen native contract + +Codex-9e8r6t /root/palette_finish,2026-09-11. Prepared before inspecting any produced staged-source output. The standalone core-only evaluator is `harness/staged-source-authoring-contract.rs`. Root subsequently granted a direct-rustc window: warnings-denied compilation, self-check and exact baseline check **passed**. This validates the evaluator/fixture, not native staged-source creation. Existing immutable CLI admission of this starting fixture also succeeded with three ordinary completeness warnings. Build/provenance is in `harness/release-staged-source-authoring-oracle-20260911.md`. + +Copy the whole `base` directory into a new isolated native work directory. Do not edit these source fixtures or overwrite an earlier checkpoint. Baseline has a root project, an empty explicit OA OperationalActivity scenario, an empty SA Functional chain and an independent Unicode comment include. All three persistent IDs are explicit; there are no existing activities/functions/participants, views, relations or traces. + +| File | Bytes | SHA256 before native output | +| --- | ---: | --- | +| `base/project.arcadia` |593|`AA844950EC4FB86F262A257E1FC370A26557EA7978D8A4CAB78265037660234C`| +| `base/notes.arcadia` |63|`AD9DE02963C20D1F43856455A797DEB05CA674659E45F2C4B96B52783024F8BD`| + +## Exact cumulative native actions + +1. OA Behavior → **Operational assessment**. The viewpoint is already **OperationalActivity**; use the semantic empty-state **Add first lifeline** action. Name the lifeline **Assessment lifeline**. Open **Create new source**, choose/retain Activity and layer-root semantic parent, and name it **Assess incident work**. Child **Use** returns to the retained parent. Verify the two distinct names, then apply the parent once. Only one root Activity plus one lifeline owned by the existing scenario, representing that Activity, should be created. Scenario steps remain empty; no occurrence, allocation, relation or view is implicit. +2. SA Behavior → **System assessment**. Use **Add first function contribution**. Name the contribution **Classify this request**. Open **Create new source**, choose/retain Function and layer-root semantic parent, name it **Classify request**, and **Use**. Apply the retained parent once. Only one root Function and one Action occurrence under the existing chain should be created; the chain steps become exactly `[NEW_CONTRIBUTION]`. Its action is the new Function, lifeline is absent, conditions are empty. An isolated first contribution has no sequence link or allocation. +3. SA Behavior → **New → State machine**. Name it **Field team operating modes**. Open **Create new owner**, explicitly select **SystemActor**, retain layer-root parent, name it **Field rescue team**, and **Use**. Check **Create the first state view**, then **Create and open** once. Only the root actor, its owned machine, and the opt-in diagram/root placement should be created. The diagram is **Field team operating modes · states**, SA StateMachine projection; default options, no routes/regions/states. Root placement is independently specified at60,60,size336×104 and passed through the shared core Layout command. + +After each creation, Save and preserve a fresh whole-directory **first-commit** checkpoint. Undo **once**, Save and preserve another directory; Redo **once**, Save and preserve another. After all three, use the real file picker to reopen the final project, Save and preserve a final directory. + +For an optional staged-only check after child **Use**, inspect or read a snapshot **before any Save**, or explicitly **Cancel the parent draft**, then Save and compare with the preceding accepted stage. Save/Save As is not a passive draft checkpoint: the existing App Save handler applies valid behavior, source and property drafts before saving. Save after Use can therefore atomically adopt the valid parent and its staged prerequisite. That is a Save-adoption route, not evidence that child Use committed early. + +Root's native 52DE82 Lifeline trial followed that Save-adoption route: Use returned to the retained parent with two accepted objects and staged **New Activity**; Ctrl+S then adopted Activity plus Lifeline and saved three files. The unchanged complete Lifeline oracle passed. A single Undo returned to two objects; persisted history checks were still ongoing at that observation. The prescribed direct parent Apply/Create route remains a separate native check. All exact source, name, ownership and identity expectations remain unchanged. + +All source/consumer names, ownership, references, defaults, membership and view geometry above are fixed expectations. The oracle borrows only unique fresh UUIDv4 roles by exact kind/layer/name (plus view projection and placement target). It never copies observed aliases, descriptions, parents, bodies, geometry, source text or diagrams wholesale. Every existing source byte and complete model field is compared after constructing the expected core-command result. + +## Checker invocation and identity authority + +After a root-authorized direct-rustc build using a matching completed `oa_core` rlib and `--edition2024 -D warnings`, let `$oracle` identify that immutable executable and `$base` the frozen baseline entry. Other variables below are preserved entry-file paths: + +Run the Windows gnullvm oracle with its matching verified runtime available, either beside the executable or through a process-scoped PATH containing the verified runtime directory and Windows system directories. Do not assume the standalone oracle directory includes `libunwind.dll`. Check the process exit status as well as its output: root's initial invocation without the runtime failed in the loader with `-1073741515` and no oracle log. A scoped PATH to the verified native 52DE82 bin plus Windows allowed the unchanged oracle to run and pass the Lifeline check. No machine-wide PATH change or oracle rebuild is required. + +```powershell +& $oracle self-check $base +& $oracle check baseline $base $base +& $oracle check lifeline $base $firstLifeline $firstLifeline +& $oracle same $base $undoLifeline $firstLifeline +& $oracle check lifeline $base $redoLifeline $firstLifeline +& $oracle check chain $base $firstChain $firstLifeline $firstChain +& $oracle same $firstLifeline $undoChain $firstChain +& $oracle check chain $base $redoChain $firstLifeline $firstChain +& $oracle check machine $base $firstMachine $firstLifeline $firstChain $firstMachine +& $oracle same $firstChain $undoMachine $firstMachine +& $oracle check machine $base $redoMachine $firstLifeline $firstChain $firstMachine +& $oracle check machine $base $reopen $firstLifeline $firstChain $firstMachine +& $oracle same $redoMachine $reopen +``` + +Every stage supplies **all preceding original first-commit role carriers separately**. Those carriers are themselves checked against the cumulative contract. A later creation therefore cannot replace a prior stage's UUID and declare the replacement its own role authority; Redo and reopen cannot borrow new identities from their own output. The baseline source bytes are embedded in the checker at build time, so replacing the runtime fixture does not change its expected input. + +The retained-file rule matches the corrected creation-v2 harness: normal checkpoints must have exactly reachable `.arcadia` files. `same EXPECTED ACTUAL UNDO_FROM` requires complete Project and reachable SourceBundle equality, and exact physical bytes equal to the explicitly proven first-commit disk map overlaid with EXPECTED's restored reachable files. Only those known detached files may remain. Unknown, changed or missing retained files fail. No file is taken from actual Undo output to define its own expected bytes. There is no broad orphan-file exemption. + +Self-check prepares deliberately false metadata/geometry role carriers, full source fixed points, complete model/byte-drift falsifiers, exact repeated-command refusal, one core-session Undo/Redo, retained-file falsifiers and independent earlier-stage identity pinning. These **passed as evaluator checks**, not native acceptance. Predicted reachable-file counts are3 after Lifeline,4 after Chain and6 after Machine. Native focus, first-action discoverability, preserved raw parent/child drafts, selection, minimum/wide layout and real one-step history still require separate observation. diff --git a/harness/fixtures/staged-source-authoring/base/notes.arcadia b/harness/fixtures/staged-source-authoring/base/notes.arcadia new file mode 100644 index 00000000..693d69be --- /dev/null +++ b/harness/fixtures/staged-source-authoring/base/notes.arcadia @@ -0,0 +1 @@ +// Preserve this independent engineering note: secours 雨 Δ. diff --git a/harness/fixtures/staged-source-authoring/base/project.arcadia b/harness/fixtures/staged-source-authoring/base/project.arcadia new file mode 100644 index 00000000..96037e65 --- /dev/null +++ b/harness/fixtures/staged-source-authoring/base/project.arcadia @@ -0,0 +1,15 @@ +arcadia 7; +project contextual_source_acceptance "Contextual source acceptance" id "d1510000-0000-4000-8000-000000000001" { + include "notes.arcadia"; + + // The viewpoint is explicit before any lifeline exists. + oa_scenario operational_assessment "Operational assessment" id "d1510000-0000-4000-8000-000000000002" { + viewpoint OperationalActivity; + steps []; + } + + // Contributions are occurrences; root Functions are separate sources. + sa_functional_chain system_assessment "System assessment" id "d1510000-0000-4000-8000-000000000003" { + steps []; + } +} diff --git a/harness/release-contextual-integration-update-ytr83r-20260911.md b/harness/release-contextual-integration-update-ytr83r-20260911.md new file mode 100644 index 00000000..b86c0a9b --- /dev/null +++ b/harness/release-contextual-integration-update-ytr83r-20260911.md @@ -0,0 +1,121 @@ +# Published contextual-source integration update — 2026-09-11 + +Codex-ytr83r /root/wsl_startup_review. Read-only preflight; root owns integration and validation. Import the cumulative **13 desktop paths** below from the parent of df6 through b2c, plus the **unchanged oracle and three-file fixture at b2c**. Merge two narrow conflicts while preserving B4 and the header correction. No later semantic dependency was found. This updates the old [df6 preflight](release-staged-source-integration-ytr83r-20260911.md); it does not execute or accept the merged application. + +## Frozen references and ancestry + +- Published implementation: `b2c3e54001fc19a05423bcd5808ceea78d13ff59` (b2c). +- Exact packaged-input reconstruction: `d5650309a93f3d590a41f8a8f0322c6500568f69` (d565), direct child of b2c. +- Original contextual commit: `df6f59562643b7771ecf0c972f8655f3e3f1c4da`; its parent and cumulative patch base is `07eb3cd12b5d0f2fa19d94ea1f5d2f8aade240ec`. +- Linear df6 → b2c ancestry: `d95b1ab17852c2dbf8ff9cd1f47507b12493054e` → `c4bad4b8de4851be4d800104be8f019b99f54f78` → `ca53f574e57381a523e946d5277b4081cff74355` → b2c. First two add historical publication/native records, not production source. ca53 changes creation.rs and source_draft.rs; b2c subsequently changes source_draft.rs. Do not stop at the WIP ca53 snapshot. +- Local starting HEAD was `fe8364e7eb47b9452b079bbf2507947dfe10b13e`, with the header-only working correction present. During review root froze that correction as `4da92ab481a2f5f9fcd88f29c90fa4785ac17c3a`, which was directly read back as HEAD; root separately reported evidence checkpoint `7831abe`. Merge base of the original local HEAD and b2c: `b6b76efc0d6092d96e793feadf529e281017c5a9`. +- Read current issue 2 comments including [publication #406](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-406), `ContextualSourceDrafts20260911` and `Authoring20260911` Wiki. Publication supersedes the Wiki's earlier packaging-pending wording. The milestone tag is `v0.2.0-preview.contextual.20260911`, pointing to b2c; d565 reconstructs packaged raw inputs. This review did not download/reverify release assets. + +Missing commit objects were fetched with `git -c gc.auto=0 fetch --no-tags --no-write-fetch-head --no-auto-maintenance gitea b2c3e54001fc19a05423bcd5808ceea78d13ff59 d5650309a93f3d590a41f8a8f0322c6500568f69` (exit 0). No ref, FETCH_HEAD, config, source or intentional index update was performed. Git read/check operations subsequently used `--no-optional-locks`; no formal before/after index hash was taken. + +## Exact minimum source import + +Use a path-scoped cumulative diff from 07eb to b2c, not an unqualified commit-range import. All paths below are relative to `crates/oa-desktop/src/` and form one coherent implementation: + +| Path | b2c blob bytes | b2c SHA256 | Current apply check | +|---|---:|---|---| +| app.rs | 210565 | B10BFA57F331DA3928D5FE27D2AEBDB13F6054C6E3D48C4CFD24FF81726094AD | pass | +| app/checkpoints.rs | 29576 | 55E5BABDCABEF767AF3BC8BE9CC3153B3F5CC0695701B890820EC0225643852E | pass | +| behavior.rs | 131449 | C9E0AEA6A24CC982D0D82EE2B9B66F250CDFAC037B36BF6B1B9D7DD38246CD3C | module hunk conflict | +| behavior/contextual_source_tests.rs | 49386 | 5F05318A6533503B9D6FEBF1EC4DD7256F192394BC1B952A7904C8175BD8DDAE | pass | +| behavior/creation.rs | 29301 | 438A245EAD397060767C790C51A8F21CD5021342627999DEB4695CF3BD22091A | pass | +| behavior/scenario.rs | 149092 | D12A4B1CAA61C08F69C4D2A64BD15568C4B7158DEA91AAC5A52A58A35ADB863C | pass | +| behavior/scenario/workspace.rs | 31975 | 790A2917D7E7102BEEAE68FF43EF1693D11132573651265DB490B560E14B7062 | pass | +| behavior/source_draft.rs | 39485 | 85D543CC288E48B4C13001E4E386B5B54D9B1E349A5AC31E4E307572F2D8FB3C | pass | +| behavior/topology.rs | 83845 | D06B2F0FC39B4814E94200297E148F1D4D97E04CB167766030AC9DF18689E3B0 | pass | +| behavior/topology/contextual.rs | 28320 | 231610428B9066A92D7405C44E2474FCFE2350FA2998926E8CF502F3392C0A07 | pass | +| behavior/topology/direct.rs | 32036 | 413AA2009EE4CBA0941836B43E41F16A04C8FC8568B6E92DB5BB08516E95C287 | pass | +| behavior/topology/view.rs | 29176 | A7D806CD621BDF857C4E4861685CD3EB556B320DE3985B9CC3D9D131DAD370DC | pass | +| guide.rs | 112981 | 51F1ACDE885635E4D9B783B56A0251B3E7315DDE4C1C05317DB55D54A857B715 | scenario walkthrough conflict | + +The exact selector is `git --no-optional-locks diff --binary 07eb3cd12b5d0f2fa19d94ea1f5d2f8aade240ec b2c3e54001fc19a05423bcd5808ceea78d13ff59 --` followed by those 13 full paths. This excludes all packaging, harness/docs, live ledgers, platform evidence and peer navigation proposals. The entire range changes no other crates, Cargo files or generated API content. Its unqualified full-tree diff does include unrelated history and must not be used as the minimum import. + +Checks used each exact binary diff in memory as stdin to `git --no-optional-locks apply --check --whitespace=nowarn -`, without applying it or writing a temporary patch. Eleven passed; behavior.rs failed at original line 14, guide.rs at original line 490. A final quiet diff confirms the other ten existing target files did not change between fe8364e and 4da92ab. The two new source paths were absent and their addition checks passed. Local behavior.rs with the checked clipping correction hashes `B47E1DF6F88282D907CD5726A03DB0BF066B60B134962A223F11842482E75946`. + +### Exact conflict resolution requirements + +In behavior.rs, preserve `mod scenario_lifecycle` and add `#[cfg(test)] mod contextual_source_tests` and `pub(crate) mod source_draft` alongside existing modules. The other peer hunks add source-event predicates, source-state adapters, and the retained unavailable-owner view. They do not touch Scene drawing, the lifecycle implementation, `box_text_clip`, or the corrected `with_clip` title/detail rendering. Do not replace the complete peer behavior.rs over B4. Keep root's header rendering and new render/workspace regressions. + +In guide.rs, keep current text and apply the peer's four subject changes: the two process/chain walkthrough sentences; contextual Lifeline source creation; the machine-owner paragraph plus adjacent new-owner sentence; and the existing draft explanation's child Back/Use/recovery prefix. The sole conflicting scenario hunk needs only this replacement of the current `Use + Lifeline...` sentence: + +> Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred. + +Preserve all current surrounding message-tool gestures, clicked fragment/operand placement, Add action/Observe state/Reuse scenario actions, Open definition/Back navigation, lifeline lifetime keywords, capsule/X selection, uncertain `?` versus unanswered-call `…`, and stored-history guidance. Whole-file replacement would restore obsolete Draw message/Destination wording and lose B4 instructions. Current direct-canvas/F2 guide content also stays. + +Keep workspace identity/reset, retained pending-source invocation, and existing same-size/new-owner viewport protections. No peer956 canvas/chooser or peer9e8 uncommitted/current text-navigation implementation belongs to this import. + +## Corrections after df6 and reconstruction boundary + +Only two production files differ df6 → b2c (589 insertions, 38 deletions including tests). source_draft.rs places compact heading, Name, wrapped explicit kind choices and semantic-parent summary before explanatory text. A single permitted kind replaces its redundant button only when it equals the actual draft selection; incomplete recovery retains an explicit repair choice. Full long-parent text remains scrollable and the Back/Use footer stays pinned. Three tests use real Iced Paragraph measurement and a composed 340/500 × 215 host, exercise initial visibility, long-parent overflow and recovered raw names, and dispatch actual kind mouse events/Unicode Name input. The final test uses 32 repeated parent segments to establish actual overflow; an earlier eight-repeat fixture failed that condition and was corrected without weakening the containment checks. + +creation.rs changes only the displayed existing-owner picker alternative when its selected ID exactly matches the staged owner: it suppresses a false missing accepted-catalog label, keeps the explicit New owner label and identifies existing participants as an alternative. Genuine missing accepted-owner and Context paths remain distinct. The layout correction does not alter source planning, ownership guards, tokens, accepted model, FormShown or canvas geometry. + +All 11 b2c → d565 content changes compare exactly equal after CRLF-to-LF normalization: creation.rs; scenario/workspace.rs; topology/contextual.rs; guide.rs; semantic_review/{artifacts_view,attachments_view,keyed,keyed_rows,keyed_tests,ordered}.rs; and docs/openapi.json. The twelfth path is .gitattributes with the corresponding 11 `-text` rules. Thus d565 is raw-byte reconstruction, not an additional semantic prerequisite. Do not import unrelated semantic-review/OpenAPI restorations or those source-specific attribute overrides merely to obtain contextual behavior. Keep the branch's source newline convention and record new merged build inputs; the table identifies peer blobs, not expected merged hashes or inherited GUI identity. + +## Required oracle, fixture and attributes + +Take these four exact b2c paths; this is the complete fixture. The oracle and both baseline files are unchanged since df6: + +| Path | Bytes | SHA256 | +|---|---:|---| +| harness/staged-source-authoring-contract.rs | 22693 | 9EB9DD3868B6CB772F907D16AE02995AD10A16F85C087012C24948E6E6FE775D | +| harness/fixtures/staged-source-authoring/README.md | 8380 | 711910A12D5F3960F32DA9AA7BC2E647F31D770C84B7FC26E232DCB3A76F8821 | +| harness/fixtures/staged-source-authoring/base/project.arcadia | 593 | AA844950EC4FB86F262A257E1FC370A26557EA7978D8A4CAB78265037660234C | +| harness/fixtures/staged-source-authoring/base/notes.arcadia | 63 | AD9DE02963C20D1F43856455A797DEB05CA674659E45F2C4B96B52783024F8BD | + +Merge only these missing preservation rules into the current .gitattributes, retaining all root-owned rules: + +```gitattributes +harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol +harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol +``` + +The corrected README is required knowledge: child Use stages, but App Save/Save As applies valid pending drafts. To prove no adoption, inspect before Save or Cancel the parent before Save. Save-adoption and direct parent Create are separate routes. Run the independently compiled oracle with the matching verified libunwind runtime available in the process-scoped PATH or beside it; inspect its exit, not merely an empty log. No machine-wide PATH change is necessary. Preserve separate first-commit UUID carriers for Undo/Redo comparisons and the exact retained-physical-file rule. + +## Pinned provenance; optional archival boundaries + +Keep this report as the local integration record. Importing the peer's nine historical reports or ten archives is unnecessary for the minimum source/test import. Their exact b2c versions remain accessible here: + +- [Implementation](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-creation-20260911.md) and [creation review](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-creation-review-20260911.md). +- [Layout review](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-layout-review-20260911.md), [independent layout review](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-layout-independent-20260911.md), and [staged-owner caption](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-staged-owner-label-20260911.md). +- [Oracle/procedure history](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-staged-source-authoring-oracle-20260911.md), [native v1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-native-20260911.md), [native v2 plan](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-native-v2-plan-20260911.md), and [completed native v2/full-suite report](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/release-contextual-source-native-v2-20260911.md). + +Optional archives are all under the pinned [b2c harness/experiments tree](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/b2c3e54001fc19a05423bcd5808ceea78d13ff59/harness/experiments). Exact directory inventories were enumerated, not copied or fully rehashed: + +| Directory | Committed files | +|---|---:| +| contextual-source-20260911 | 40 | +| contextual-source-native-20260911 | 54 | +| contextual-source-reopen-20260911 | 11 | +| contextual-source-layout-reboot-20260911 | 15 | +| contextual-source-layout-postboot-20260911 | 11 | +| contextual-source-layout-verified-20260911 | 13 | +| contextual-source-layout-full-20260911 | 10 | +| contextual-source-recovery-20260911 | 27 | +| contextual-source-machine-v2-20260911 | 50 | +| contextual-source-lifeline-v2-20260911 | 25 | + +If root later copies any such checksum-bound archive, preserve its exact bytes with that archive's scoped `-text` rule and the corresponding whitespace exceptions from b2c .gitattributes; do not copy the complete attribute file. Some historical .png files are JPEG despite their suffix and must retain their recorded original names/bytes. No archive import is needed now. Exclude peer AGENTS/PROJECT_GOAL/PLAN/STATE/TASK_LEDGER, WSL/capacity/platform evidence, CLI publication records, unrelated OA scenario archives and current navigation proposals. + +## Evidence limits and next integration gate + +The published GUI is `5657BAB5C841939B66F901EFE6C0FFB2D7CBBE8CC5F111A0CA3DE8073D3846CE`; its test executable is `75AB0E151D85473631D5A145527F3F6B9768A3CD61CE66A0603A574E09557017`. The pinned report records focused 26, full 679 (zero failed/ignored/filtered, exit 0 at 15:47:53 UTC), strict Clippy/fmt/build, minimum-window source visibility/recovery/cancellation, direct Lifeline Create/Save/Undo/Redo, and Machine owner/Create/Save/Undo/Redo/fresh-process reopen. These are peer artifact results, not executed merged results. Older GUI52 and intermediate layout hashes remain historical; they must not replace the final source hashes above. + +After root's selective merge, inspect the complete union and conflicts, then run the required combined desktop/focused/full/strict gates once on frozen merged inputs. Preserve B4 lifecycle and header clipping regressions, contextual/admission/recovery and real-layout tests, guide and direct-canvas/workspace regressions. The native merged source/consumer Save/Undo/Redo/reopen acceptance still needs matching runtime and complete Project/reachable SourceBundle/physical file-map/UUID oracles. The unchanged core/server/CLI gate is independent of this 13-path desktop delta; it does not establish desktop acceptance. + +After these read-only checks, root reported importing the 13 source paths and four fixture/oracle blobs, preserving the module union and exact guide sentence. Root also reported the independent server/CLI gate exit 0 at 17:28:56 UTC (68 passed across seven targets). Those are subsequent root actions, not this preflight execution; the report does not recheck or accept that now-modified desktop union. + +This worker ran no Cargo, native UI, oracle, WSL, network configuration, merge, checkout, staging or source edit. A first attempted in-memory check used unavailable `python` and stopped before checks; the successful checks used .NET byte streams and Git. Only this new report was written. Root retains actual integration, Gitea consolidation, release and remaining workflow ownership. + +## Root integration and independent union review + +Root applied the eleven clean source patches and merged only the documented behavior module insertion and guide Lifeline sentence. Scoped rustfmt passed. The complete behavior.rs suffix beginning `struct BoxItem`, including lifecycle rendering, marker arbitration, hit testing and the header correction, compares byte-identically with4da92ab. Core/server/CLI/Cargo have no diff. Four oracle/fixture files match the exact b2c bytes in the table above. + +A separate GPT-6 Astra reviewer found no material integration issue: preserved B4/guide union, context-bound source Back, modal-first Escape, success-only consumer selection and atomic source/history paths remain coherent. Imported files match peer content; workspace retains only the existing omitted explanatory comment above unchanged sensor identity code. This is static review, not new desktop/native acceptance. The combined source now proceeds to its own frozen Windows desktop gate. + +Live coordination moved to [issue5](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5) following owner issue1/comment421. Issue4 separately tracks the newly reported child-work failure. diff --git a/harness/release-lifecycle-native-ytr83r-20260911.md b/harness/release-lifecycle-native-ytr83r-20260911.md index 55a65ea8..942e9971 100644 --- a/harness/release-lifecycle-native-ytr83r-20260911.md +++ b/harness/release-lifecycle-native-ytr83r-20260911.md @@ -110,7 +110,7 @@ passed exit0 at16:45:59.383UTC in1216.612seconds. The affected scenario workspac The typography-wide fixture exposed a real neutral-header defect. At minimum80%, the OA receiver card occupied approximately x515–679 while its18-W name extended to about730. Overflow remained at64%,51.2%, minimum Fit69%, and wide100/125/156.25/195.3125%. The Create capsule text stayed contained. Header and Create clicks still selected the distinct original Lifeline102 and occurrence110, with the full18-W name retained in Properties. -SA displayed the18-CJK-character name with available glyphs at wide100% and125%; its neutral header overflowed while the Create capsule stayed contained. Header102 and Create110 selected correctly and Properties retained the full name. A later attempt to increase CJK zoom was interrupted by the user; no156% outcome is inferred. The user then explicitly reauthorized Computer Use. A fresh main-window capture failed with `window capture timed out: timed out waiting on channel`; the prescribed fresh-window recovery failed with `computer-use request timed out: activate_window`. Root stopped UI input. No wide-fixture passive Save/oracle or combining/emoji native acceptance is claimed. +SA displayed the18-CJK-character name with available glyphs at wide100% and125%; its neutral header overflowed while the Create capsule stayed contained. Header102 and Create110 selected correctly and Properties retained the full name. A later attempt to increase CJK zoom was interrupted by the user; no156% outcome is inferred. The user then explicitly reauthorized Computer Use. A fresh main-window capture failed with `window capture timed out: timed out waiting on channel`; the prescribed fresh-window recovery failed with `computer-use request timed out: activate_window`. Root stopped UI input at that point. Combining/emoji native acceptance remains unclaimed. The [pre-correction archive](lifecycle-header-overflow-before-archive-20260911.md) preserves exactly12 original wide-name screenshots and the affected-test log/status, with complete byte readback. The [bounded correction](lifecycle-header-overflow-correction-ytr83r-20260911.md) adds clipping to the existing shared card text path; it requires its own compile/test and matching GUI verification. Existing model and source checks do not certify corrected pixels. @@ -119,3 +119,7 @@ Native toolbar steps provide51.2%/100%/195.3125%, plus minimum Fit; exact0.5x/2x ## Git evidence preservation Before checkpoint commit, root compared the complete index inventory and every Git blob byte against all122 files in the three closed OA/SA/pre-correction archives, plus the preserved historical STATE file:123 exact files,8,470,143 bytes. Nineteen raw logs required explicit inclusion because the repository ignores new log files. The final inventory and all byte comparisons passed; no screenshot or log normalization was accepted. + +## Subsequent pre-correction passive Save + +After the server/CLI compiler completed, a fresh selected-window capture initially showed an occluding application; root issued no coordinate input against that image. Explicit activation of the already selected Studio window recovered a valid1102×732 Studio capture at125% with the SA CJK header selected. Root clicked Save all, observed `All source files saved`, then froze the complete two-file wide fixture and ran the independent `same` oracle against its immutable admitted typography-wide prediction. Exit0: all identities, fields, reachable source and physical bytes match. The20128-byte project retains SHA2566a18ee1f246321bdf8ab0e7e8e5d4eba134ce486ed5224c4ffd61c7342c3a7d3 and the86-byte rationale retains8509f8d8a136d808cf98a1bb0fe50348b8a24409f30dee4ee65b62bf5cb9e6bf. This remains old production29084050/GUI47332A6E; it proves passive persistence, not corrected clipping or the cause of tool recovery. Original supplemental screenshot/checkpoint/log are preserved separately from the closed12-image archive. diff --git a/harness/staged-source-authoring-contract.rs b/harness/staged-source-authoring-contract.rs new file mode 100644 index 00000000..f2ad9e49 --- /dev/null +++ b/harness/staged-source-authoring-contract.rs @@ -0,0 +1,635 @@ +//! Independent contextual-source native oracle. No desktop producer is imported. +//! self-check BASE | check STAGE BASE ACTUAL [FIRST_LIFELINE [FIRST_CHAIN [FIRST_MACHINE]]] +//! same EXPECTED ACTUAL [UNDO_FROM] +use oa_core::{source_project::*, *}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +const ACTIVITY: &str = "Assess incident work"; +const LIFELINE: &str = "Assessment lifeline"; +const FUNCTION: &str = "Classify request"; +const CONTRIBUTION: &str = "Classify this request"; +const ACTOR: &str = "Field rescue team"; +const MACHINE: &str = "Field team operating modes"; +const VIEW: &str = "Field team operating modes · states"; +const SCENARIO: u128 = 0xd1510000000040008000000000000002; +const CHAIN: u128 = 0xd1510000000040008000000000000003; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Stage { + Baseline, + Lifeline, + Chain, + Machine, +} +impl Stage { + fn parse(text: &str) -> Self { + match text { + "baseline" => Self::Baseline, + "lifeline" => Self::Lifeline, + "chain" => Self::Chain, + "machine" => Self::Machine, + _ => panic!("Stage must be baseline, lifeline, chain or machine"), + } + } + fn additions(self) -> usize { + match self { + Self::Baseline => 0, + Self::Lifeline => 2, + Self::Chain => 4, + Self::Machine => 6, + } + } +} + +type DiskSources = BTreeMap>; +fn source_paths(root: &Path, here: &Path, paths: &mut DiskSources) { + for entry in fs::read_dir(here).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + assert!( + !kind.is_symlink(), + "Checkpoint files must be ordinary files" + ); + if kind.is_dir() { + source_paths(root, &entry.path(), paths); + } else if entry.path().extension().is_some_and(|ext| ext == "arcadia") { + assert!(kind.is_file()); + paths.insert( + entry + .path() + .strip_prefix(root) + .unwrap() + .to_str() + .unwrap() + .replace('\\', "/"), + fs::read(entry.path()).unwrap(), + ); + } + } +} +fn load_checkpoint(entry: &Path) -> (CompiledSourceProject, DiskSources) { + let files = source_files::load_project(entry, ProjectLimits::default()).unwrap(); + assert!( + !files + .diagnostics + .iter() + .any(|d| d.diagnostic.severity == validation::Severity::Error), + "Loader errors: {:?}", + files.diagnostics + ); + let raw = files.reachable_bundle(); + let compiled = compile_bundle(&raw, ProjectLimits::default()).unwrap(); + assert_eq!( + compiled.bundle, raw, + "No normalization, migration or missing identity is allowed in saved output" + ); + execute_batch(&compiled.decoded.project, &[]).unwrap(); + let mut disk = BTreeMap::new(); + let root = entry.parent().unwrap(); + source_paths(root, root, &mut disk); + (compiled, disk) +} +fn predicted_disk(reachable: &SourceBundle, undo_from: Option<&DiskSources>) -> DiskSources { + let mut files = undo_from.cloned().unwrap_or_default(); + files.extend( + reachable + .files + .iter() + .map(|(path, text)| (path.clone(), text.as_bytes().to_vec())), + ); + files +} +fn load(entry: &Path) -> CompiledSourceProject { + let (compiled, files) = load_checkpoint(entry); + assert_eq!( + files, + predicted_disk(&compiled.bundle, None), + "Ordinary checkpoint has unexpected detached source files; Undo needs an explicit origin" + ); + compiled +} +fn baseline(entry: &Path) -> CompiledSourceProject { + let base = load(entry); + assert_eq!( + base.bundle, + SourceBundle { + entry: "project.arcadia".into(), + files: BTreeMap::from([ + ( + "project.arcadia".into(), + include_str!("fixtures/staged-source-authoring/base/project.arcadia").into() + ), + ( + "notes.arcadia".into(), + include_str!("fixtures/staged-source-authoring/base/notes.arcadia").into() + ), + ]) + }, + "Baseline bytes differ from the pre-observation fixture embedded in this executable" + ); + let p = &base.decoded.project; + assert_eq!(p.elements.len(), 2); + assert!(p.relations.is_empty()); + assert!(p.diagrams.is_empty()); + assert_eq!( + p.elements[&Id::from_u128(SCENARIO)].name, + "Operational assessment" + ); + assert_eq!(p.elements[&Id::from_u128(CHAIN)].name, "System assessment"); + base +} + +struct Roles<'a> { + actual: &'a Project, + baseline: &'a Project, + used: BTreeSet, +} +impl Roles<'_> { + fn fresh(&mut self, ids: Vec, role: &str) -> Id { + assert_eq!(ids.len(), 1, "Exactly one literal role required: {role}"); + let id = ids[0]; + assert_eq!(id.get_version_num(), 4, "Fresh role must be UUIDv4: {role}"); + assert!(!self.baseline.contains(id)); + assert!(self.used.insert(id)); + id + } + fn element(&mut self, kind: Kind, layer: Layer, name: &str) -> Id { + self.fresh( + self.actual + .elements + .values() + .filter(|e| e.kind() == kind && e.layer() == layer && e.name == name) + .map(|e| e.id) + .collect(), + name, + ) + } + fn view(&mut self, machine: Id) -> Id { + self.fresh( + self.actual + .diagrams + .values() + .filter(|d| { + d.name == VIEW + && d.layer == Layer::Sa + && d.projection == ProjectionKind::StateMachine { machine } + }) + .map(|d| d.id) + .collect(), + VIEW, + ) + } + fn placement(&mut self, view: Id, machine: Id) -> Id { + self.fresh( + self.actual.diagrams[&view] + .nodes + .values() + .filter(|n| n.element == machine) + .map(|n| n.id) + .collect(), + "first view machine placement", + ) + } +} +fn element( + project: &Project, + id: Id, + name: &str, + parent: Option, + body: ElementBody, +) -> Element { + Element { + id, + alias: project.fresh_alias(name, "element", id), + name: name.into(), + description: String::new(), + parent, + body, + } +} +fn stage_commands(before: &Project, stage: Stage, roles: &mut Roles<'_>) -> Vec { + match stage { + Stage::Baseline => vec![], + Stage::Lifeline => { + let source = roles.element(Kind::Activity, Layer::Oa, ACTIVITY); + let line = roles.element(Kind::Lifeline, Layer::Oa, LIFELINE); + vec![ + Command::CreateElement(element( + before, + source, + ACTIVITY, + None, + ElementBody::Oa(OaBody::Activity), + )), + Command::CreateElement(element( + before, + line, + LIFELINE, + Some(Id::from_u128(SCENARIO)), + ElementBody::shared_in( + Layer::Oa, + SharedBody::Lifeline { + represented: Some(source), + }, + ), + )), + ] + } + Stage::Chain => { + let source = roles.element(Kind::Function, Layer::Sa, FUNCTION); + let step = roles.element(Kind::Occurrence, Layer::Sa, CONTRIBUTION); + vec![ + Command::CreateElement(element( + before, + source, + FUNCTION, + None, + ElementBody::Sa(SaBody::Function), + )), + Command::CreateElement(element( + before, + step, + CONTRIBUTION, + Some(Id::from_u128(CHAIN)), + ElementBody::shared_in( + Layer::Sa, + SharedBody::Occurrence { + body: OccurrenceBody::Action { + action: Some(source), + lifeline: None, + }, + precondition: String::new(), + postcondition: String::new(), + }, + ), + )), + Command::SetSteps(Id::from_u128(CHAIN), vec![step]), + ] + } + Stage::Machine => { + let actor = roles.element(Kind::SystemActor, Layer::Sa, ACTOR); + let machine = roles.element(Kind::StateMachine, Layer::Sa, MACHINE); + let diagram = roles.view(machine); + let placement = roles.placement(diagram, machine); + let owner = element(before, actor, ACTOR, None, ElementBody::Sa(SaBody::Actor)); + let record = element( + before, + machine, + MACHINE, + Some(actor), + ElementBody::shared_in(Layer::Sa, SharedBody::StateMachine), + ); + let p = execute_batch( + before, + &[ + Command::CreateElement(owner.clone()), + Command::CreateElement(record.clone()), + ], + ) + .unwrap(); + let view = Diagram { + id: diagram, + alias: p.fresh_alias(VIEW, "state_view", diagram), + name: VIEW.into(), + description: String::new(), + layer: Layer::Sa, + projection: ProjectionKind::StateMachine { machine }, + routes: vec![], + view_options: Default::default(), + nodes: BTreeMap::from([( + placement, + NodePlacement { + id: placement, + alias: p.fresh_alias( + &format!("{MACHINE} placement"), + "placement", + placement, + ), + element: machine, + position: Position { x: 60., y: 60. }, + size: Size { + width: 336., + height: 104., + }, + collapsed: false, + port_anchors: vec![], + }, + )]), + }; + vec![ + Command::CreateElement(owner), + Command::CreateElement(record), + Command::CreateDiagram(view), + Command::Layout(diagram), + ] + } + } +} +fn predict( + base: &CompiledSourceProject, + stage: Stage, + carriers: &[&Project], +) -> CompiledSourceProject { + let mut result = base.clone(); + let mut used = BTreeSet::new(); + for (index, current) in [Stage::Lifeline, Stage::Chain, Stage::Machine] + .into_iter() + .filter(|s| *s <= stage) + .enumerate() + { + let mut roles = Roles { + actual: carriers[index], + baseline: &base.decoded.project, + used, + }; + let command = Command::Batch(stage_commands(&result.decoded.project, current, &mut roles)); + used = roles.used; + let plan = prepare_command(&result.decoded.project, &command).unwrap(); + result = update_bundle_with_identity_map( + &result, + &plan.project, + &plan.identity_map, + ProjectLimits::default(), + ) + .unwrap(); + } + assert_eq!( + result.decoded.project.elements.len(), + base.decoded.project.elements.len() + stage.additions() + ); + assert_eq!( + result.decoded.project.diagrams.len(), + usize::from(stage == Stage::Machine) + ); + assert_eq!( + result.decoded.project.relations, + base.decoded.project.relations + ); + assert_eq!(result.decoded.project.traces, base.decoded.project.traces); + assert_eq!( + result.decoded.project.artifact_links, + base.decoded.project.artifact_links + ); + result +} +fn equal(a: &CompiledSourceProject, b: &CompiledSourceProject) -> bool { + a.decoded.project == b.decoded.project && a.bundle == b.bundle +} +fn assert_equal(a: &CompiledSourceProject, b: &CompiledSourceProject) { + assert_eq!( + a.decoded.project, b.decoded.project, + "Complete Project differs" + ); + assert_eq!(a.bundle, b.bundle, "Complete exact SourceBundle differs"); +} +fn test_id(n: u128) -> Id { + Id::from_u128(0xd2520000000040008000000000000000 + n) +} +fn false_carrier(base: &Project) -> Project { + let mut p = base.clone(); + for (n, kind, layer, name) in [ + (1, Kind::Activity, Layer::Oa, ACTIVITY), + (2, Kind::Lifeline, Layer::Oa, LIFELINE), + (3, Kind::Function, Layer::Sa, FUNCTION), + (4, Kind::Occurrence, Layer::Sa, CONTRIBUTION), + (5, Kind::SystemActor, Layer::Sa, ACTOR), + (6, Kind::StateMachine, Layer::Sa, MACHINE), + ] { + p.elements.insert( + test_id(n), + Element { + id: test_id(n), + alias: format!("false_alias_{n}"), + name: name.into(), + description: "Never copy this observed metadata".into(), + parent: None, + body: ElementBody::new(kind, layer).unwrap(), + }, + ); + } + p.diagrams.insert( + test_id(7), + Diagram { + id: test_id(7), + alias: "false_view_alias".into(), + name: VIEW.into(), + description: "Never copy this".into(), + layer: Layer::Sa, + projection: ProjectionKind::StateMachine { + machine: test_id(6), + }, + routes: vec![], + view_options: Default::default(), + nodes: BTreeMap::from([( + test_id(8), + NodePlacement { + id: test_id(8), + alias: "false_placement_alias".into(), + element: test_id(6), + position: Position { x: -123., y: 987. }, + size: Size { + width: 77., + height: 33., + }, + collapsed: true, + port_anchors: vec![], + }, + )]), + }, + ); + p +} +fn self_check(base: &CompiledSourceProject) { + let carrier = false_carrier(&base.decoded.project); + let mut previous = base.clone(); + for stage in [Stage::Lifeline, Stage::Chain, Stage::Machine] { + let expected = predict(base, stage, &[&carrier; 3]); + assert_equal( + &expected, + &predict(base, stage, &[&expected.decoded.project; 3]), + ); + assert_equal( + &expected, + &compile_bundle(&expected.bundle, ProjectLimits::default()).unwrap(), + ); + let mut roles = Roles { + actual: &carrier, + baseline: &base.decoded.project, + used: BTreeSet::new(), + }; + let command = Command::Batch(stage_commands(&previous.decoded.project, stage, &mut roles)); + let mut session = Session::try_new(previous.decoded.project.clone()).unwrap(); + session.execute(command.clone()).unwrap(); + assert_eq!(session.project(), &expected.decoded.project); + assert!( + session.execute(command).is_err(), + "Exact repeated creation command must not duplicate identities" + ); + assert_eq!(session.project(), &expected.decoded.project); + session.undo(); + assert_eq!(session.project(), &previous.decoded.project); + session.redo(); + assert_eq!(session.project(), &expected.decoded.project); + let mut changed = expected.clone(); + changed + .decoded + .project + .elements + .get_mut(&test_id(1)) + .unwrap() + .description = "unrequested".into(); + assert!(!equal(&expected, &changed)); + let mut changed_source = expected.bundle.clone(); + changed_source + .files + .get_mut("notes.arcadia") + .unwrap() + .push_str("// drift\n"); + let changed = compile_bundle(&changed_source, ProjectLimits::default()).unwrap(); + assert_eq!(changed.decoded.project, expected.decoded.project); + assert!(!equal(&expected, &changed)); + let committed_disk = predicted_disk(&expected.bundle, None); + let undo_disk = predicted_disk(&previous.bundle, Some(&committed_disk)); + assert!(undo_disk.len() >= previous.bundle.files.len()); + let mut unknown = undo_disk.clone(); + unknown.insert("unknown.arcadia".into(), b"// unknown".to_vec()); + assert_ne!(unknown, undo_disk); + let detached: Vec<_> = undo_disk + .keys() + .filter(|p| !previous.bundle.files.contains_key(*p)) + .cloned() + .collect(); + assert!( + !detached.is_empty(), + "Each selected source operation should add a module" + ); + let mut changed = undo_disk.clone(); + changed.get_mut(&detached[0]).unwrap().push(b' '); + assert_ne!(changed, undo_disk); + let mut missing = undo_disk.clone(); + missing.remove(&detached[0]); + assert_ne!(missing, undo_disk); + println!( + "PASS {stage:?}: complete model/source fixed point, false-carrier metadata ignored, repeated-command refusal, one Undo/Redo, comment drift and explicit retained-file falsifiers; {} reachable files", + expected.bundle.files.len() + ); + previous = expected; + } + let expected = predict(base, Stage::Machine, &[&carrier; 3]); + let mut wrong = expected.clone(); + wrong + .decoded + .project + .elements + .get_mut(&test_id(2)) + .unwrap() + .body = ElementBody::shared_in(Layer::Oa, SharedBody::Lifeline { represented: None }); + assert!(!equal(&expected, &wrong)); + let mut wrong = expected.clone(); + wrong + .decoded + .project + .elements + .get_mut(&test_id(6)) + .unwrap() + .parent = None; + assert!(!equal(&expected, &wrong)); + let mut wrong = expected.clone(); + wrong + .decoded + .project + .diagrams + .get_mut(&test_id(7)) + .unwrap() + .nodes + .get_mut(&test_id(8)) + .unwrap() + .position + .x += 1.; + assert!(!equal(&expected, &wrong)); + println!( + "PASS wrong source binding, machine owner and view geometry rejected; no native acceptance claimed" + ); + let mut changed_prior_role = carrier.clone(); + let mut changed_activity = changed_prior_role.elements.remove(&test_id(1)).unwrap(); + changed_activity.id = test_id(91); + changed_prior_role + .elements + .insert(test_id(91), changed_activity); + let pinned = predict(base, Stage::Chain, &[&carrier, &changed_prior_role]); + assert_equal(&pinned, &predict(base, Stage::Chain, &[&carrier; 3])); + assert!(!equal( + &pinned, + &predict(base, Stage::Chain, &[&changed_prior_role; 3]) + )); + println!("PASS earlier-stage role identities remain pinned independently of a later carrier"); +} +fn main() { + assert_eq!(SCHEMA_VERSION, 7); + let args: Vec<_> = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some("self-check") => { + assert_eq!(args.len(), 3); + self_check(&baseline(Path::new(&args[2]))); + } + Some("check") => { + assert!( + args.len() >= 5, + "check STAGE BASE ACTUAL [FIRST_LIFELINE [FIRST_CHAIN [FIRST_MACHINE]]]" + ); + let stage = Stage::parse(&args[2]); + let stages: Vec<_> = [Stage::Lifeline, Stage::Chain, Stage::Machine] + .into_iter() + .filter(|s| *s <= stage) + .collect(); + assert_eq!( + args.len(), + 5 + stages.len(), + "Supply every stage's original first-commit role carrier separately" + ); + let base = baseline(Path::new(&args[3])); + let actual = load(Path::new(&args[4])); + let carriers: Vec<_> = args[5..].iter().map(|path| load(Path::new(path))).collect(); + let projects: Vec<_> = carriers.iter().map(|c| &c.decoded.project).collect(); + for (index, carrier_stage) in stages.into_iter().enumerate() { + assert_equal(&predict(&base, carrier_stage, &projects), &carriers[index]); + } + let expected = predict(&base, stage, &projects); + assert_equal(&expected, &actual); + println!( + "PASS {stage:?}: complete Project + {} exact source/disk files, original role UUIDs", + actual.bundle.files.len() + ); + } + Some("same") => { + assert!( + matches!(args.len(), 4 | 5), + "same EXPECTED ACTUAL [UNDO_FROM]" + ); + let expected = load(Path::new(&args[2])); + let (actual, disk) = load_checkpoint(Path::new(&args[3])); + assert_equal(&expected, &actual); + let origin = args + .get(4) + .map(|path| predicted_disk(&load(Path::new(path)).bundle, None)); + assert_eq!( + disk, + predicted_disk(&expected.bundle, origin.as_ref()), + "Only exact first-commit retained files are permitted after Undo" + ); + println!( + "PASS exact model/identity/{} reachable sources; {} exact disk files ({} proven retained)", + actual.bundle.files.len(), + disk.len(), + disk.len() - actual.bundle.files.len() + ); + } + _ => panic!( + "self-check BASE | check STAGE BASE ACTUAL [FIRST_LIFELINE [FIRST_CHAIN [FIRST_MACHINE]]] | same EXPECTED ACTUAL [UNDO_FROM]" + ), + } +}