Complete source 6 engineering workflows and preserve release validation evidence
This commit is contained in:
+168
-22
@@ -1,5 +1,13 @@
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod diagram_selection_tests;
|
||||
mod hosted;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
mod view_copy_tests;
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
document::Document,
|
||||
@@ -288,6 +296,7 @@ pub enum Pending {
|
||||
HostedOpen,
|
||||
New,
|
||||
Demo,
|
||||
Walkthrough,
|
||||
Open,
|
||||
Close(window::Id),
|
||||
}
|
||||
@@ -346,6 +355,7 @@ pub enum Message {
|
||||
InspectorTab(crate::navigation::InspectorTab),
|
||||
RevealInDiagram(Id, Id),
|
||||
AddDiagram,
|
||||
DuplicateDiagram(Id),
|
||||
AddArtifact,
|
||||
AddTrace,
|
||||
Field(String, String),
|
||||
@@ -354,6 +364,7 @@ pub enum Message {
|
||||
FieldAppend(String, Value),
|
||||
FieldValuePath(String, crate::data_values::FieldPathKey),
|
||||
FieldValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
FieldReferenceBrowse(String, String, usize),
|
||||
FieldVariant(String, Value),
|
||||
FieldArray(String, Option<usize>, i32),
|
||||
Apply,
|
||||
@@ -897,6 +908,44 @@ impl App {
|
||||
fn diagram_for(&self, layer: Layer, element: Id) -> Option<Id> {
|
||||
preferred_diagram(self.project(), self.active_diagram, layer, element)
|
||||
}
|
||||
/// Navigation after an accepted review is not another model operation.
|
||||
/// Callers retain their workspace policy; keep an already visible target's
|
||||
/// camera and never leave an old-layer diagram behind after counterpart focus.
|
||||
fn focus_after_review(&mut self, target: Id, destination: Option<Id>) -> bool {
|
||||
if !self.project().contains(target) {
|
||||
return false;
|
||||
}
|
||||
let previous = (self.layer, self.active_diagram);
|
||||
self.selected = target;
|
||||
self.selected_node = None;
|
||||
if let Some(layer) = self.project().layer_of(target) {
|
||||
let retained = self.active_diagram.filter(|id| {
|
||||
self.project().diagrams.get(id).is_some_and(|d| {
|
||||
d.layer == layer
|
||||
&& (d.nodes.values().any(|n| n.element == target)
|
||||
|| self.project().elements.get(&target).is_some_and(|e| {
|
||||
e.kind() == Kind::Port
|
||||
&& project_view(self.project(), *id)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|port| port.element == target)
|
||||
}))
|
||||
})
|
||||
});
|
||||
self.layer = layer;
|
||||
self.active_diagram = destination
|
||||
.filter(|id| {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.layer == layer)
|
||||
})
|
||||
.or(retained)
|
||||
.or_else(|| self.diagram_for(layer, target));
|
||||
}
|
||||
self.refresh_draft();
|
||||
previous != (self.layer, self.active_diagram)
|
||||
}
|
||||
fn select(&mut self, id: Id) {
|
||||
if self.selected == id {
|
||||
return;
|
||||
@@ -1530,7 +1579,14 @@ impl App {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
Message::Guide(event) => self.guide.update(event),
|
||||
Message::Guide(event) => {
|
||||
if self.guide.update(event) {
|
||||
return iced::widget::operation::snap_to(
|
||||
crate::guide::ARTICLE_SCROLL_ID,
|
||||
iced::widget::scrollable::RelativeOffset::START,
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::ExactStrings(enabled) => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match d.set_exact(enabled) {
|
||||
@@ -1575,6 +1631,22 @@ impl App {
|
||||
.insert(path, crate::forms::ValueBrowser { scope, page });
|
||||
}
|
||||
}
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
if let Some(draft) = &mut self.draft {
|
||||
let page = if draft
|
||||
.reference_browsers
|
||||
.get(&path)
|
||||
.is_some_and(|b| b.query == query)
|
||||
{
|
||||
page
|
||||
} else {
|
||||
0
|
||||
};
|
||||
draft
|
||||
.reference_browsers
|
||||
.insert(path, crate::forms::ReferenceBrowser { query, page });
|
||||
}
|
||||
}
|
||||
Message::FieldValuePath(path, key) => {
|
||||
if let Some(draft) = &mut self.draft
|
||||
&& let Err(error) = draft.value_path(&path, key)
|
||||
@@ -1799,13 +1871,11 @@ impl App {
|
||||
match state.command(self.project()) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
if let Some(target) = focus {
|
||||
self.selected = target;
|
||||
if let Some(layer) = self.project().layer_of(target)
|
||||
{
|
||||
self.layer = layer;
|
||||
}
|
||||
self.refresh_draft();
|
||||
if let Some(target) = focus
|
||||
&& self.focus_after_review(target, None)
|
||||
&& self.page == Page::Diagram
|
||||
{
|
||||
self.fit();
|
||||
}
|
||||
self.status = "Semantic review applied · model, source and baseline form one undoable change".into();
|
||||
return Task::none();
|
||||
@@ -1827,11 +1897,19 @@ impl App {
|
||||
&& matches!(&workflow, crate::workflow::Workflow::Ports(_));
|
||||
let transition_focus = match &workflow {
|
||||
crate::workflow::Workflow::Ports(state) => Some(state.focus()),
|
||||
crate::workflow::Workflow::Transition(t) => {
|
||||
t.plan.changes.first().map(|c| c.target)
|
||||
}
|
||||
crate::workflow::Workflow::Transition(t) => t
|
||||
.request
|
||||
.sources
|
||||
.first()
|
||||
.and_then(|source| t.plan.changes.iter().find(|c| c.source == *source))
|
||||
.map(|c| c.target),
|
||||
_ => None,
|
||||
};
|
||||
let behavior_transition = matches!(&workflow,
|
||||
crate::workflow::Workflow::Transition(t)
|
||||
if t.request.sources.first().is_some_and(|source|
|
||||
crate::behavior::selected_behavior(self.project(), *source).is_some()));
|
||||
let previous_behavior_context = self.state_view_identity();
|
||||
match workflow.update(self.project(), event) {
|
||||
Ok(Some(mut candidate)) => {
|
||||
let focus = transition_focus
|
||||
@@ -1849,25 +1927,33 @@ impl App {
|
||||
.find(|id| !self.project().traces.contains_key(id))
|
||||
.copied()
|
||||
});
|
||||
let behavior_owner = focus
|
||||
.filter(|_| behavior_transition)
|
||||
.and_then(|id| crate::behavior::selected_behavior(&candidate, id));
|
||||
let destination = focus.and_then(|id| {
|
||||
candidate.layer_of(id).and_then(|layer| {
|
||||
preferred_diagram(&candidate, self.active_diagram, layer, id)
|
||||
})
|
||||
});
|
||||
if let (Some(id), Some(diagram)) = (focus, destination) {
|
||||
if let (None, Some(id), Some(diagram)) =
|
||||
(behavior_owner, focus, destination)
|
||||
{
|
||||
reveal_port_owner(&mut candidate, diagram, id);
|
||||
}
|
||||
if self.execute(Command::Replace(candidate)) {
|
||||
if let Some(id) = focus {
|
||||
self.selected = id;
|
||||
if let Some(layer) = self.project().layer_of(id) {
|
||||
self.layer = layer;
|
||||
self.active_diagram =
|
||||
destination.or_else(|| self.diagram_for(layer, id));
|
||||
}
|
||||
self.refresh_draft();
|
||||
self.focus_after_review(id, destination);
|
||||
}
|
||||
if !stay_in_catalog {
|
||||
if let Some(owner) = behavior_owner {
|
||||
self.selected_node = None;
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
if self.reset_state_view_after_navigation(
|
||||
previous_behavior_context,
|
||||
) {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
} else if !stay_in_catalog {
|
||||
self.page = Page::Diagram;
|
||||
self.fit();
|
||||
}
|
||||
@@ -2105,6 +2191,7 @@ impl App {
|
||||
layer: self.layer,
|
||||
projection: ProjectionKind::Allocation,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
};
|
||||
if self.execute(Command::CreateDiagram(d)) {
|
||||
@@ -2113,6 +2200,39 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::DuplicateDiagram(source) => {
|
||||
if !self.project().diagrams.contains_key(&source) {
|
||||
self.status = "The saved view to duplicate no longer exists.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
if self.apply() {
|
||||
let source = if source == original {
|
||||
self.selected
|
||||
} else {
|
||||
source
|
||||
};
|
||||
match crate::view_copy::command(self.project(), source) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.active_diagram = Some(self.selected);
|
||||
self.layer = self.project().diagrams[&self.selected].layer;
|
||||
self.selected_node = None;
|
||||
self.selected_bend = None;
|
||||
self.page = Page::Diagram;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.route_state_diagram_workspace();
|
||||
if self.page == Page::Diagram {
|
||||
self.fit();
|
||||
}
|
||||
self.status =
|
||||
"View copied; engineering elements are shared.".into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::AddArtifact => {
|
||||
if self.apply() {
|
||||
let id = Id::new_v4();
|
||||
@@ -2206,11 +2326,28 @@ impl App {
|
||||
});
|
||||
}
|
||||
Message::Diagram(choice) => {
|
||||
// Reject a stale picker event before applying unrelated property input.
|
||||
if !self.project().diagrams.contains_key(&choice.id) {
|
||||
self.status = "Diagram no longer exists.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let previous_diagram = self.active_diagram;
|
||||
let follow_view = previous_diagram == Some(self.selected);
|
||||
if self.apply() {
|
||||
self.active_diagram = Some(choice.id);
|
||||
if let Some(d) = self.project().diagrams.get(&choice.id) {
|
||||
// Apply may have changed the current diagram's UUID. A picker
|
||||
// event for that same view must follow its new identity.
|
||||
let diagram = if previous_diagram == Some(choice.id) {
|
||||
self.active_diagram.unwrap_or(choice.id)
|
||||
} else {
|
||||
choice.id
|
||||
};
|
||||
self.active_diagram = Some(diagram);
|
||||
if let Some(d) = self.project().diagrams.get(&diagram) {
|
||||
self.layer = d.layer;
|
||||
}
|
||||
if follow_view {
|
||||
self.selected = diagram;
|
||||
}
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.fit();
|
||||
@@ -2841,6 +2978,7 @@ impl App {
|
||||
layer: self.layer,
|
||||
projection: preferred,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
}));
|
||||
diagram_target = Some(view);
|
||||
@@ -3009,6 +3147,13 @@ impl App {
|
||||
self.reset(Project::demo(), None);
|
||||
Task::none()
|
||||
}
|
||||
Pending::Walkthrough => match crate::home::walkthrough_path() {
|
||||
Ok(path) => self.load_from(path),
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
Task::none()
|
||||
}
|
||||
},
|
||||
Pending::Open => self.choose_file(FileOperation::Open),
|
||||
Pending::Close(id) => {
|
||||
self.retire_checkpoint();
|
||||
@@ -4440,6 +4585,7 @@ mod port_view_tests {
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ impl App {
|
||||
home::Event::New => self.handle(Message::Request(Pending::New)),
|
||||
home::Event::Open => self.handle(Message::Request(Pending::Open)),
|
||||
home::Event::Example => self.handle(Message::Request(Pending::Demo)),
|
||||
home::Event::Walkthrough => self.handle(Message::Request(Pending::Walkthrough)),
|
||||
home::Event::Guide => self.update(Message::Page(Page::Guide)),
|
||||
home::Event::Resume => self.update(Message::Page(self.last_workspace_page)),
|
||||
home::Event::OpenRecent(path) => {
|
||||
@@ -151,6 +152,7 @@ impl App {
|
||||
self.reset(restored.document.project().clone(), None);
|
||||
self.hosted.leave_document();
|
||||
self.document = restored.document;
|
||||
self.migration = self.document.compiled().decoded.migration.clone();
|
||||
self.selected = if self.project().layer_of(restored.selected).is_some()
|
||||
|| restored.selected == self.project().id
|
||||
{
|
||||
@@ -184,7 +186,11 @@ impl App {
|
||||
} else {
|
||||
Page::Diagram
|
||||
};
|
||||
self.status="Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged.".into();
|
||||
self.status = if self.migration.is_some() {
|
||||
"Recovered source 5 as source 6 in an unsaved local copy. Draft text is preserved; workflow previews were rebuilt and semantic impact approvals need review. Choose Save as to save it."
|
||||
} else {
|
||||
"Recovered an unsaved local copy. Choose Save as to save it; the original file and hosted model are unchanged."
|
||||
}.into();
|
||||
self.request_lint()
|
||||
}
|
||||
}
|
||||
@@ -279,6 +285,28 @@ mod tests {
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walkthrough_request_keeps_unfinished_source_behind_the_normal_pending_guard() {
|
||||
let mut app = App::with_project(Project::demo());
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = format!(
|
||||
"{}\nunfinished walkthrough draft",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
app.source_workspace
|
||||
.edit(raw.clone(), app.source_editor.cursor());
|
||||
let task = app.update(Message::Home(home::Event::Walkthrough));
|
||||
assert!(matches!(app.pending, Some(Pending::Walkthrough)));
|
||||
assert!(!app.busy);
|
||||
assert!(iced_runtime::task::into_stream(task).is_none());
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.pending.is_none());
|
||||
assert_eq!(app.source_workspace.text(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
fn retained_snapshot(app: &App) -> recovery::Snapshot {
|
||||
recovery::Snapshot::capture(
|
||||
app.canvas_document,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! A view picker follows a view inspector without stealing semantic selection.
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let project = language::parse(
|
||||
r#"arcadia 6; project views "View switching" {
|
||||
oa_activity work "Inspect work" {}
|
||||
diagram first "Original concern" {}
|
||||
diagram second "Commands review" {}
|
||||
node original_work { diagram @first; element @work; }
|
||||
node copied_work { diagram @second; element @work; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let id = |alias| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, value)| *value == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let (first, second, work) = (id("first"), id("second"), id("work"));
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(second);
|
||||
app.selected = second;
|
||||
app.refresh_draft();
|
||||
(app, first, second, work)
|
||||
}
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id,
|
||||
label: "Captured choice".into(),
|
||||
}));
|
||||
}
|
||||
fn field(app: &mut App, path: &str, value: &str) {
|
||||
let _ = app.update(Message::Field(path.into(), value.into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_visible_view_follows_its_clean_inspector_without_a_model_edit() {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.active_diagram, Some(original));
|
||||
assert_eq!(app.selected, original);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, original);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Original concern"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
field(&mut app, "/name", "Edited visible original");
|
||||
assert!(app.apply());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&original].name,
|
||||
"Edited visible original"
|
||||
);
|
||||
assert_eq!(app.project().diagrams[©].name, "Commands review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_view_draft_blocks_the_switch_and_retains_raw_input() {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
field(&mut app, "/id", "-");
|
||||
let raw = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.active_diagram, Some(copy));
|
||||
assert_eq!(app.selected, copy);
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pending_view_edit_applies_to_old_view_then_inspects_the_new_view() {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
let before = app.document.snapshot();
|
||||
field(&mut app, "/name", "Reviewed copy");
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.project().diagrams[©].name, "Reviewed copy");
|
||||
assert_eq!(
|
||||
app.project().diagrams[&original],
|
||||
before.decoded.project.diagrams[&original]
|
||||
);
|
||||
assert_eq!(app.selected, original);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, original);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_uuid_edit_follows_the_same_view_or_a_different_picker_target() {
|
||||
for same_view in [false, true] {
|
||||
let (mut app, original, copy, _) = fixture();
|
||||
let changed = Id::from_u128(0xabcd123440008000000000000001);
|
||||
field(&mut app, "/id", &changed.to_string());
|
||||
choose(&mut app, if same_view { copy } else { original });
|
||||
let expected = if same_view { changed } else { original };
|
||||
assert!(!app.project().diagrams.contains_key(©));
|
||||
assert!(app.project().diagrams.contains_key(&changed));
|
||||
assert_eq!(app.active_diagram, Some(expected));
|
||||
assert_eq!(app.selected, expected);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, expected);
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_switch_preserves_an_independent_semantic_inspection() {
|
||||
let (mut app, original, _, work) = fixture();
|
||||
let _ = app.update(Message::Select(work));
|
||||
let before = app.document.snapshot();
|
||||
choose(&mut app, original);
|
||||
assert_eq!(app.active_diagram, Some(original));
|
||||
assert_eq!(app.selected, work);
|
||||
assert_eq!(app.draft.as_ref().unwrap().original, work);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_diagram_choice_never_applies_an_unrelated_valid_draft() {
|
||||
let (mut app, _, copy, _) = fixture();
|
||||
field(&mut app, "/name", "Pending copy name");
|
||||
let before = app.document.snapshot();
|
||||
choose(&mut app, Id::new_v4());
|
||||
assert_eq!(app.active_diagram, Some(copy));
|
||||
assert_eq!(app.selected, copy);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Pending copy name"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.status.contains("no longer exists"));
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Accepted semantic review keeps selection, layer and visible diagram coherent.
|
||||
use super::*;
|
||||
use crate::{
|
||||
semantic_review::{RowEvent, State},
|
||||
workflow::{Event, Workflow},
|
||||
};
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn state(app: &App) -> &State {
|
||||
let Some(Workflow::Semantic(state)) = &app.workflow else {
|
||||
panic!("{}", app.status)
|
||||
};
|
||||
state
|
||||
}
|
||||
fn row_event(app: &mut App, event: RowEvent) {
|
||||
let event = state(app).address_event(event);
|
||||
send(app, Message::Workflow(Event::Semantic(event)));
|
||||
}
|
||||
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Confirm stable pressure", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Confirm safe pressure", None)
|
||||
.unwrap();
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "confirm_trace".into(),
|
||||
name: "Confirmation refinement".into(),
|
||||
description: String::new(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let (source, target, source_layer, target_layer) = if reverse {
|
||||
(sa, oa, Layer::Sa, Layer::Oa)
|
||||
} else {
|
||||
(oa, sa, Layer::Oa, Layer::Sa)
|
||||
};
|
||||
let from = p.ensure_diagram(source_layer);
|
||||
p.place(from, source, Position { x: 91., y: 143. });
|
||||
let to = destination_view.then(|| {
|
||||
let view = p.ensure_diagram(target_layer);
|
||||
p.place(view, target, Position { x: 487., y: 213. });
|
||||
view
|
||||
});
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = source_layer;
|
||||
app.active_diagram = Some(from);
|
||||
app.selected = source;
|
||||
app.refresh_draft();
|
||||
(app, source, target, from, to)
|
||||
}
|
||||
fn name_take(app: &mut App, source: Id) -> Project {
|
||||
send(app, Message::StartSemanticReview(source));
|
||||
let facet = FacetSelector::Header(HeaderFacet::Name);
|
||||
row_event(app, RowEvent::Select(facet.clone()));
|
||||
row_event(
|
||||
app,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(state(app).can_apply(), "{:?}", state(app).error);
|
||||
let Some(Workflow::Semantic(state)) = &mut app.workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
let command = state.command(app.document.project()).unwrap();
|
||||
execute_batch(app.document.project(), &[command]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_name_take_selects_the_counterpart_view_in_both_directions_with_one_exact_history_entry()
|
||||
{
|
||||
for reverse in [false, true] {
|
||||
let (mut app, source, target, from, to) = fixture(reverse, true);
|
||||
app.search = format!("@{}", app.project().elements[&source].alias);
|
||||
let query = app.search.clone();
|
||||
let before = app.document.snapshot();
|
||||
let expected = name_take(&mut app, source);
|
||||
app.selected_node = app.project().diagrams[&from].nodes.keys().next().copied();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.project().diagrams, before.decoded.project.diagrams);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.layer, app.project().elements[&target].layer());
|
||||
assert_eq!(app.active_diagram, to);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()].layer,
|
||||
app.layer
|
||||
);
|
||||
// refresh_draft deliberately selects the target's visible placement.
|
||||
// The invariant is that the old-layer placement cannot survive.
|
||||
let placement = app.selected_node.expect("visible counterpart placement");
|
||||
assert_eq!(
|
||||
app.project().diagrams[&to.unwrap()].nodes[&placement].element,
|
||||
target
|
||||
);
|
||||
assert!(!app.project().diagrams[&from].nodes.contains_key(&placement));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.search, query);
|
||||
assert!(app.workflow.is_none());
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
let accepted = app.document.snapshot();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted.bundle, LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_review_without_a_destination_view_clears_the_old_layer_canvas_without_creating_a_view()
|
||||
{
|
||||
let (mut app, source, target, _, _) = fixture(false, false);
|
||||
let expected = name_take(&mut app, source);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
assert_eq!(app.project().diagrams.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_already_showing_the_target_keeps_its_camera_and_query() {
|
||||
let (mut app, source, target, _, to) = fixture(false, true);
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = to;
|
||||
app.selected = target;
|
||||
app.zoom = 1.31;
|
||||
app.pan = iced::Vector::new(-219., 88.);
|
||||
app.search = "pressure".into();
|
||||
app.refresh_draft();
|
||||
let expected = name_take(&mut app, source);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.active_diagram, to);
|
||||
assert_eq!(app.zoom, 1.31);
|
||||
assert_eq!(app.pan, iced::Vector::new(-219., 88.));
|
||||
assert_eq!(app.search, "pressure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_semantic_source_writeback_retains_review_camera_and_original_navigation() {
|
||||
let (mut app, source, _, from, _) = fixture(false, true);
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 1;
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
app.zoom = 1.21;
|
||||
app.pan = iced::Vector::new(72., -44.);
|
||||
app.search = "confirmation".into();
|
||||
let before = app.document.snapshot();
|
||||
let _valid_model = name_take(&mut app, source);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, source);
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.active_diagram, Some(from));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.zoom, 1.21);
|
||||
assert_eq!(app.pan, iced::Vector::new(72., -44.));
|
||||
assert_eq!(app.search, "confirmation");
|
||||
assert!(state(&app).error.is_some());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Transition navigation must not add presentation edits to a behavior review.
|
||||
use super::*;
|
||||
use crate::workflow::{Event, Workflow};
|
||||
use oa_core::transitions::*;
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn element(
|
||||
p: &mut Project,
|
||||
number: u128,
|
||||
kind: Kind,
|
||||
layer: Layer,
|
||||
name: &str,
|
||||
parent: Option<Id>,
|
||||
) -> Id {
|
||||
let mut e = p.make_element(kind, layer, name, parent).unwrap();
|
||||
e.id = Id::from_u128(number);
|
||||
let id = e.id;
|
||||
p.elements.insert(id, e);
|
||||
id
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let other = if layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let work_kind = |l| {
|
||||
if l == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
}
|
||||
};
|
||||
let work = element(&mut p, 1, work_kind(layer), layer, "Assess pressure", None);
|
||||
let reused = element(&mut p, 10, work_kind(other), other, "Assess pressure", None);
|
||||
let root = element(&mut p, 100, kind, layer, "Restore service", None);
|
||||
if kind == Kind::Scenario {
|
||||
let line = element(&mut p, 2, Kind::Lifeline, layer, "Assessment", Some(root));
|
||||
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(work),
|
||||
};
|
||||
}
|
||||
let view = p.ensure_diagram(other);
|
||||
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
|
||||
p.place(view, reused, Position { x: 873., y: 421. });
|
||||
let mut app = App::with_project(p);
|
||||
app.layer = layer;
|
||||
app.page = Page::Behavior;
|
||||
app.selected = root;
|
||||
app.behavior.active_owner = Some(root);
|
||||
app.refresh_draft();
|
||||
(app, root, work, reused)
|
||||
}
|
||||
|
||||
fn review(
|
||||
app: &mut App,
|
||||
selected: Id,
|
||||
mapping: Option<(Id, Id)>,
|
||||
destination: Option<Id>,
|
||||
) -> (Id, Project) {
|
||||
app.selected = selected;
|
||||
app.refresh_draft();
|
||||
send(app, Message::StartTransition);
|
||||
let Some(Workflow::Transition(t)) = &mut app.workflow else {
|
||||
panic!("{}", app.status)
|
||||
};
|
||||
t.request.scope = DependencyScope::Subtree;
|
||||
t.request.destination_diagram = destination;
|
||||
if let Some((source, target)) = mapping {
|
||||
t.request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
}
|
||||
t.plan = plan_transition(app.document.project(), &t.request).unwrap();
|
||||
assert!(t.plan.conflicts.is_empty(), "{:?}", t.plan.conflicts);
|
||||
let target = t
|
||||
.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == selected)
|
||||
.unwrap()
|
||||
.target;
|
||||
let expected = apply_transition(app.document.project(), &t.plan).unwrap();
|
||||
(target, expected)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_transition_focuses_requested_counterpart_and_preserves_unrelated_views_in_both_directions()
|
||||
{
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut app, root, work, reused) = fixture(layer, Kind::Scenario);
|
||||
let stale_selection = crate::behavior::scenario::CanvasEvent::Select(
|
||||
app.scenario_context().unwrap(),
|
||||
Id::from_u128(2),
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let (target, expected) = review(&mut app, root, Some((work, reused)), None);
|
||||
let Some(Workflow::Transition(t)) = &app.workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_ne!(
|
||||
t.plan.changes.first().unwrap().source,
|
||||
root,
|
||||
"Fixture must expose a dependency before the selected root"
|
||||
);
|
||||
app.selected_node = Some(Id::from_u128(999));
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&expected,
|
||||
"Desktop must preserve the core candidate exactly"
|
||||
);
|
||||
assert_eq!(app.project().diagrams, before.decoded.project.diagrams);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, target);
|
||||
assert_eq!(app.selected_node, None);
|
||||
assert!(app.workflow.is_none());
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::ScenarioCanvas(stale_selection.clone()));
|
||||
assert_eq!(app.selected, target);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted.bundle, LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
let undone_selection = app.selected;
|
||||
send(&mut app, Message::ScenarioCanvas(stale_selection));
|
||||
assert_eq!(app.selected, undone_selection);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_behavior_internal_and_process_roots_keep_their_workspace_without_creating_a_view() {
|
||||
for (layer, kind) in [
|
||||
(Layer::Oa, Kind::Scenario),
|
||||
(Layer::Sa, Kind::Scenario),
|
||||
(Layer::Oa, Kind::Process),
|
||||
(Layer::Sa, Kind::FunctionalChain),
|
||||
] {
|
||||
let (mut app, root, work, reused) = fixture(layer, kind);
|
||||
let mut p = app.project().clone();
|
||||
p.diagrams.clear();
|
||||
app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
let selected = if kind == Kind::Scenario {
|
||||
Id::from_u128(2)
|
||||
} else {
|
||||
root
|
||||
};
|
||||
let (target, expected) = review(&mut app, selected, Some((work, reused)), None);
|
||||
let owner = crate::behavior::selected_behavior(&expected, target).unwrap();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().diagrams.is_empty());
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(owner));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_destination_is_honored_and_ordinary_work_transition_still_places_in_architecture() {
|
||||
let (mut app, root, work, reused) = fixture(Layer::Oa, Kind::Scenario);
|
||||
let destination = *app.project().diagrams.keys().next().unwrap();
|
||||
let (target, expected) = review(&mut app, root, Some((work, reused)), Some(destination));
|
||||
assert!(
|
||||
expected.diagrams[&destination]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == target)
|
||||
);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
let (mut app, _, work, _) = fixture(Layer::Oa, Kind::Scenario);
|
||||
let (target, _) = review(&mut app, work, None, None);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.selected, target);
|
||||
assert!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.element == target)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_and_typed_action_transitions_resolve_the_owner_and_reusing_that_context_retains_camera()
|
||||
{
|
||||
for selected_action in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let owner = element(&mut p, 1, Kind::Entity, Layer::Oa, "Control room", None);
|
||||
let counterpart = element(&mut p, 10, Kind::System, Layer::Sa, "Control room", None);
|
||||
let machine = element(
|
||||
&mut p,
|
||||
100,
|
||||
Kind::StateMachine,
|
||||
Layer::Oa,
|
||||
"Pressure modes",
|
||||
Some(owner),
|
||||
);
|
||||
let region = element(&mut p, 101, Kind::Region, Layer::Oa, "Main", Some(machine));
|
||||
let state = element(&mut p, 102, Kind::State, Layer::Oa, "Waiting", Some(region));
|
||||
let work = element(&mut p, 2, Kind::Activity, Layer::Oa, "Assess alarm", None);
|
||||
let action = Id::from_u128(103);
|
||||
p.relations.insert(
|
||||
action,
|
||||
Relation {
|
||||
id: action,
|
||||
alias: "assess_entry".into(),
|
||||
name: "Assess on entry".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::StateAction {
|
||||
source: state,
|
||||
target: work,
|
||||
phase: StateActionKind::Entry,
|
||||
},
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
let selected = if selected_action { action } else { machine };
|
||||
let (target, expected) = review(&mut app, selected, Some((owner, counterpart)), None);
|
||||
let target_machine = crate::behavior::selected_behavior(&expected, target).unwrap();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target_machine));
|
||||
assert!(
|
||||
app.project().diagrams.is_empty(),
|
||||
"The state workspace must offer explicit view creation"
|
||||
);
|
||||
app.behavior.diagram.zoom = 1.17;
|
||||
app.behavior.diagram.pan = iced::Vector::new(-73., 146.);
|
||||
let epoch = app.canvas_epoch;
|
||||
let (same_target, _) = review(&mut app, selected, Some((owner, counterpart)), None);
|
||||
assert_eq!(same_target, target);
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert_eq!(app.behavior.diagram.zoom, 1.17);
|
||||
assert_eq!(app.behavior.diagram.pan, iced::Vector::new(-73., 146.));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_source_writeback_keeps_review_and_navigation_and_invalid_draft_still_blocks_start() {
|
||||
let (mut app, root, work, reused) = fixture(Layer::Oa, Kind::Scenario);
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 1;
|
||||
app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
let (_, _) = review(&mut app, root, Some((work, reused)), None);
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::Workflow(Event::Apply));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.workflow.is_some());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, root);
|
||||
assert_eq!(app.behavior.active_owner, Some(root));
|
||||
assert!(!app.document.can_undo());
|
||||
app.workflow = None;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/alias".into(), "not an alias!".into()),
|
||||
);
|
||||
let draft = serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
send(&mut app, Message::StartTransition);
|
||||
assert!(app.workflow.is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! App-level copy behavior, with separate state/architecture view identities.
|
||||
use super::*;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
architecture: Id,
|
||||
states: Id,
|
||||
machine: Id,
|
||||
region: Id,
|
||||
state: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let project = language::parse(
|
||||
r#"arcadia 3; project copied_views "Copied views" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
diagram architecture "Architecture" {}
|
||||
diagram state_view "Modes view" { projection { StateMachine { machine @modes; }; }; }
|
||||
node crew_view { diagram @architecture; element @crew; }
|
||||
node main_view { diagram @state_view; element @main; }
|
||||
node ready_view { diagram @state_view; element @ready; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let identity = |alias| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let architecture = identity("architecture");
|
||||
let states = identity("state_view");
|
||||
let machine = identity("modes");
|
||||
let region = identity("main");
|
||||
let state = identity("ready");
|
||||
let mut app = App::with_project(project);
|
||||
// Initial view UUID order must not determine the test's workspace.
|
||||
app.page = Page::Behavior;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram.diagram = Some(states);
|
||||
app.selected = state;
|
||||
app.refresh_draft();
|
||||
Fixture {
|
||||
app,
|
||||
architecture,
|
||||
states,
|
||||
machine,
|
||||
region,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn raw_property(app: &App) -> serde_json::Value {
|
||||
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_the_current_state_view_uses_its_own_identity_and_is_one_source_undo() {
|
||||
let mut f = fixture();
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.states);
|
||||
assert_eq!(f.app.active_diagram, Some(f.architecture));
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.page, Page::Behavior);
|
||||
assert_eq!(f.app.behavior.active_owner, Some(f.machine));
|
||||
let copied = f.app.state_context().unwrap().diagram;
|
||||
assert_ne!(copied, f.states);
|
||||
assert_ne!(copied, f.architecture);
|
||||
assert_eq!(f.app.active_diagram, Some(copied));
|
||||
assert_eq!(f.app.selected, copied);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&copied].projection.state_machine(),
|
||||
Some(f.machine)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&copied]
|
||||
.nodes
|
||||
.values()
|
||||
.map(|node| node.element)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([f.region, f.state])
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let accepted = f.app.document.snapshot();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(f.app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*f.app.project()
|
||||
);
|
||||
let mut only_original = f.app.project().clone();
|
||||
only_original.diagrams.remove(&copied);
|
||||
assert_eq!(only_original, before.decoded.project);
|
||||
send(&mut f.app, Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
send(&mut f.app, Message::Redo);
|
||||
assert_eq!(f.app.project(), &accepted.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_architecture_keeps_architecture_even_with_a_retained_state_view() {
|
||||
let mut f = fixture();
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.active_diagram = Some(f.architecture);
|
||||
f.app.selected = f.architecture;
|
||||
f.app.refresh_draft();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.architecture));
|
||||
let copied = f.app.active_diagram.unwrap();
|
||||
assert_ne!(copied, f.architecture);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert!(f.app.state_context().is_none());
|
||||
assert_eq!(f.app.project().diagrams[&copied].name, "Architecture copy");
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&copied].projection,
|
||||
f.app.project().diagrams[&f.architecture].projection
|
||||
);
|
||||
assert_eq!(f.app.project().diagrams[&copied].nodes.len(), 1);
|
||||
assert_eq!(f.app.selected, copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_copy_does_not_apply_an_unrelated_valid_property_draft() {
|
||||
let mut f = fixture();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Field("/name".into(), "Pending state rename".into()),
|
||||
);
|
||||
let raw = raw_property(&f.app);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
send(&mut f.app, Message::DuplicateDiagram(Id::new_v4()));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(raw_property(&f.app), raw);
|
||||
assert_eq!(f.app.selected, f.state);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(f.app.status.contains("no longer exists"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rejection_preserves_invalid_property_and_pending_behavior_drafts() {
|
||||
let mut f = fixture();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Field("/alias".into(), "invalid alias!".into()),
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let raw = raw_property(&f.app);
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(raw_property(&f.app), raw);
|
||||
assert!(!f.app.document.can_undo());
|
||||
|
||||
let mut f = fixture();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Behavior(crate::behavior::Event::States(
|
||||
crate::behavior::states::Event::Start(
|
||||
f.machine,
|
||||
crate::behavior::states::Tool::Vertex,
|
||||
f.region,
|
||||
),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Behavior(crate::behavior::Event::States(
|
||||
crate::behavior::states::Event::Name("Unfinished nested state".into()),
|
||||
)),
|
||||
);
|
||||
assert!(f.app.behavior.has_pending());
|
||||
let raw = f.app.behavior.recovery_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.states);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_rejection_preserves_an_incomplete_source_buffer() {
|
||||
let mut f = fixture();
|
||||
let raw = "arcadia 6; project unfinished \"Unfinished source\" {".to_owned();
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::SourceEdit(text_editor::Action::SelectAll),
|
||||
);
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(raw.clone()),
|
||||
))),
|
||||
);
|
||||
assert!(f.app.source_dirty());
|
||||
let before = f.app.document.snapshot();
|
||||
let source = f.app.source_workspace.bundle();
|
||||
send(&mut f.app, Message::DuplicateDiagram(f.states));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.source_workspace.bundle(), source);
|
||||
assert_eq!(f.app.source_workspace.text(), raw);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
@@ -773,7 +773,9 @@ pub fn selected_behavior(p: &Project, selected: Id) -> Option<Id> {
|
||||
.filter(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::StateTransition | RelationKind::SequenceLink
|
||||
RelationKind::StateTransition
|
||||
| RelationKind::SequenceLink
|
||||
| RelationKind::StateAction
|
||||
)
|
||||
})
|
||||
.map(|r| r.source())
|
||||
|
||||
@@ -669,6 +669,7 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
&& matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
)
|
||||
@@ -677,7 +678,9 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
ScenarioViewpoint::Functional => r.kind() == RelationKind::FunctionalExchange,
|
||||
ScenarioViewpoint::ParticipantInteraction => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction | RelationKind::BoundaryExchange
|
||||
RelationKind::Interaction
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::BoundaryExchange
|
||||
),
|
||||
ScenarioViewpoint::BoundaryInterface => matches!(
|
||||
r.kind(),
|
||||
@@ -687,17 +690,37 @@ fn exchanges(p: &Project, d: &Draft) -> Vec<Choice> {
|
||||
}
|
||||
&& (!d.fixed_endpoints
|
||||
|| d.sender.zip(d.receiver).is_some_and(|(sender, receiver)| {
|
||||
validation::lifeline_matches_endpoint(p, sender, r.source()) == Some(true)
|
||||
&& validation::lifeline_matches_endpoint(p, receiver, r.target())
|
||||
== Some(true)
|
||||
let mut body = d.message_body();
|
||||
if let OccurrenceBody::Exchange { exchange, .. } = &mut body {
|
||||
*exchange = Some(r.id);
|
||||
}
|
||||
validation::message_endpoints(p, &body).is_some_and(|(source, target)| {
|
||||
validation::lifeline_matches_endpoint(p, sender, source) == Some(true)
|
||||
&& validation::lifeline_matches_endpoint(p, receiver, target)
|
||||
== Some(true)
|
||||
})
|
||||
}))
|
||||
})
|
||||
.map(|r| Choice {
|
||||
id: r.id,
|
||||
label: format!(
|
||||
"{} · {} → {}",
|
||||
"{} · {} {} {}",
|
||||
r.name,
|
||||
p.named(r.source()),
|
||||
if matches!(
|
||||
r.body,
|
||||
RelationBody::CommunicationMean {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
) {
|
||||
"↔"
|
||||
} else {
|
||||
"→"
|
||||
},
|
||||
p.named(r.target())
|
||||
),
|
||||
})
|
||||
@@ -837,6 +860,38 @@ impl Draft {
|
||||
lines(p, self.owner)
|
||||
.into_iter()
|
||||
.filter(|line| {
|
||||
if self
|
||||
.exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.is_some_and(|r| {
|
||||
matches!(
|
||||
r.body,
|
||||
RelationBody::CommunicationMean { .. }
|
||||
| RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
{
|
||||
let mut body = self.message_body();
|
||||
if let OccurrenceBody::Exchange {
|
||||
sender: from,
|
||||
receiver: to,
|
||||
..
|
||||
} = &mut body
|
||||
{
|
||||
if sender {
|
||||
*from = Some(line.id);
|
||||
} else {
|
||||
*to = Some(line.id);
|
||||
}
|
||||
}
|
||||
return !matches!(
|
||||
validation::resolve_message_endpoints(p, &body),
|
||||
validation::MessageEndpointResolution::Conflict
|
||||
);
|
||||
}
|
||||
endpoint.is_none_or(|endpoint| {
|
||||
validation::lifeline_matches_endpoint(p, line.id, endpoint) == Some(true)
|
||||
})
|
||||
@@ -2315,6 +2370,81 @@ pub(crate) mod direct_tests {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn reverse_communication_canvas_choices_and_correlated_reply_share_core_direction() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Station", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::CommunicationMean, "Radio", a, b);
|
||||
let owner = scenario(&mut p, "Participant dialogue");
|
||||
if let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
p.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
{
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
}
|
||||
let la = lifeline(&mut p, owner, a);
|
||||
let lb = lifeline(&mut p, owner, b);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin_message(
|
||||
&p,
|
||||
direct_tests::context(owner),
|
||||
Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
},
|
||||
lb,
|
||||
la,
|
||||
MessageStyle::CallReply,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
exchanges(&p, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == exchange)
|
||||
);
|
||||
state
|
||||
.update(&p, Event::Exchange(named_choice(&p, exchange)))
|
||||
.unwrap();
|
||||
let mut document =
|
||||
crate::document::Document::from_project(p.clone(), Default::default()).unwrap();
|
||||
let original = document.bundle().clone();
|
||||
document
|
||||
.execute(state.prepare_pending(&p).unwrap())
|
||||
.unwrap();
|
||||
let accepted = document.project();
|
||||
let steps = accepted.elements[&owner].steps().unwrap();
|
||||
assert_eq!(steps.len(), 2);
|
||||
for (id, expected) in [(steps[0], (b, a)), (steps[1], (a, b))] {
|
||||
let Some(SharedBody::Occurrence { body, .. }) = accepted.elements[&id].shared() else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
validation::message_endpoints(accepted, body),
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
let after = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &original);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &after);
|
||||
let mut directed = p.clone();
|
||||
if let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut directed.relations.get_mut(&exchange).unwrap().body
|
||||
{
|
||||
*oriented = true;
|
||||
}
|
||||
assert!(
|
||||
!exchanges(&directed, state.draft.as_ref().unwrap())
|
||||
.iter()
|
||||
.any(|c| c.id == exchange)
|
||||
);
|
||||
}
|
||||
fn scenario(p: &mut Project, name: &str) -> Id {
|
||||
let mut e = p
|
||||
.make_element(Kind::Scenario, Layer::Oa, name, None)
|
||||
|
||||
@@ -1538,6 +1538,7 @@ mod tests {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
for (element, x, y, width, height) in [
|
||||
|
||||
@@ -293,6 +293,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
let members: Vec<_> = project
|
||||
|
||||
@@ -41,6 +41,7 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
if let Some(context) = context.filter(|context| context.diagram == id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Duplicate view", Message::DuplicateDiagram(id), true),
|
||||
item("Fit diagram", diagram_event(Event::Fit(context)), true),
|
||||
item(
|
||||
"Create another state view",
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::collections::BTreeSet;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Interaction,
|
||||
CommunicationMean,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
}
|
||||
@@ -13,7 +14,7 @@ pub enum Tool {
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction],
|
||||
Layer::Oa => &[Self::Interaction, Self::CommunicationMean],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
}
|
||||
}
|
||||
@@ -21,6 +22,7 @@ impl Tool {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Interaction",
|
||||
Self::CommunicationMean => "Communication means",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
}
|
||||
@@ -29,6 +31,9 @@ impl Tool {
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::CommunicationMean => {
|
||||
"Click an operational entity or actor, then another participant. Communication is bidirectional unless you change its orientation."
|
||||
}
|
||||
Self::FunctionalExchange => {
|
||||
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
|
||||
}
|
||||
@@ -40,6 +45,9 @@ impl Tool {
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::CommunicationMean => project.elements.get(&source).is_some_and(|e| {
|
||||
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
|
||||
}),
|
||||
Self::Interaction => project
|
||||
.elements
|
||||
.get(&source)
|
||||
@@ -57,6 +65,9 @@ impl Tool {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::CommunicationMean => {
|
||||
validation::can_connect(project, RelationKind::CommunicationMean, source, target)
|
||||
}
|
||||
Self::Interaction => {
|
||||
validation::can_connect(project, RelationKind::Interaction, source, target)
|
||||
}
|
||||
@@ -129,6 +140,9 @@ impl Tool {
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::CommunicationMean => {
|
||||
RelationBody::new(RelationKind::CommunicationMean, source, target)
|
||||
}
|
||||
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
|
||||
Self::BoundaryExchange => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
@@ -210,7 +224,9 @@ fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> St
|
||||
project.named(id).to_owned()
|
||||
}
|
||||
};
|
||||
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
|
||||
let arrow = if tool == Tool::CommunicationMean
|
||||
|| (tool == Tool::BoundaryExchange && !boundary_oriented(project, source))
|
||||
{
|
||||
"—"
|
||||
} else {
|
||||
"→"
|
||||
@@ -273,7 +289,10 @@ mod tests {
|
||||
let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
|
||||
assert_eq!(
|
||||
Tool::for_layer(Layer::Oa),
|
||||
&[Tool::Interaction, Tool::CommunicationMean]
|
||||
);
|
||||
assert_eq!(
|
||||
Tool::Interaction.targets(&p, source),
|
||||
BTreeSet::from([target])
|
||||
@@ -289,6 +308,27 @@ mod tests {
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_communication_uses_participants_without_creating_ports() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Entity, Layer::Oa, "Field team");
|
||||
let b = element(&mut p, Kind::Actor, Layer::Oa, "Coordinator");
|
||||
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Report");
|
||||
let sa = element(&mut p, Kind::System, Layer::Sa, "Controller");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::CommunicationMean.targets(&p, a), BTreeSet::from([b]));
|
||||
for invalid in [a, activity, sa, Id::new_v4()] {
|
||||
assert!(Tool::CommunicationMean.command(&p, a, invalid).is_err());
|
||||
}
|
||||
let command = Tool::CommunicationMean.command(&p, a, b).unwrap();
|
||||
let (accepted, relation) = created(&p, &command);
|
||||
assert!(matches!(relation.body, RelationBody::CommunicationMean {
|
||||
source, target, oriented: false, ref items,
|
||||
} if source == a && target == b && items.is_empty()));
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
|
||||
let mut p = Project::empty();
|
||||
@@ -532,6 +572,8 @@ mod tests {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
|
||||
let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
|
||||
let oa_team = element(&mut p, Kind::Entity, Layer::Oa, "Response team");
|
||||
let oa_actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
|
||||
let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
@@ -547,6 +589,7 @@ mod tests {
|
||||
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
for (tool, source, target) in [
|
||||
(Tool::Interaction, oa_a, oa_b),
|
||||
(Tool::CommunicationMean, oa_team, oa_actor),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
] {
|
||||
|
||||
@@ -27,6 +27,9 @@ mod lifecycle_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/port_hit_tests.rs"]
|
||||
mod port_hit_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/view_options_tests.rs"]
|
||||
mod view_options_tests;
|
||||
|
||||
#[path = "diagram/port_labels.rs"]
|
||||
mod port_labels;
|
||||
@@ -35,6 +38,7 @@ pub use port_labels::PortLabels;
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub project: &'a Project,
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
pub diagram: Id,
|
||||
pub selected: Id,
|
||||
pub zoom: f32,
|
||||
@@ -429,7 +433,15 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn preview_view(&self, state: &State) -> ProjectedView {
|
||||
oa_core::projection::project_diagram(self.project, &self.preview_diagram(state))
|
||||
self.projected(&self.preview_diagram(state))
|
||||
}
|
||||
fn projected(&self, diagram: &Diagram) -> ProjectedView {
|
||||
match self.view_index {
|
||||
Some(index) => {
|
||||
oa_core::projection::project_diagram_with_index(self.project, diagram, index)
|
||||
}
|
||||
None => oa_core::projection::project_diagram(self.project, diagram),
|
||||
}
|
||||
}
|
||||
fn scene(&self, state: &State) -> Arc<Scene> {
|
||||
let preview = if state.context == Some(self.context) {
|
||||
@@ -460,7 +472,7 @@ impl DiagramCanvas<'_> {
|
||||
} else {
|
||||
self.project.diagrams[&self.diagram].clone()
|
||||
};
|
||||
let view = oa_core::projection::project_diagram(self.project, &diagram);
|
||||
let view = self.projected(&diagram);
|
||||
let routed = Arc::new(
|
||||
if let Some(gesture) = state
|
||||
.route_drag
|
||||
@@ -1837,6 +1849,7 @@ mod tests {
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
@@ -1909,6 +1922,7 @@ mod tests {
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
document: p.id,
|
||||
diagram,
|
||||
@@ -2032,6 +2046,7 @@ mod tests {
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
context,
|
||||
selected: p.id,
|
||||
@@ -2198,6 +2213,7 @@ mod tests {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom,
|
||||
@@ -2271,6 +2287,7 @@ mod tests {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 0.5,
|
||||
@@ -2365,6 +2382,7 @@ mod tests {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 1.,
|
||||
@@ -2490,6 +2508,7 @@ mod tests {
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 0.7,
|
||||
@@ -2541,6 +2560,7 @@ mod tests {
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
zoom: 1.,
|
||||
@@ -2612,6 +2632,7 @@ mod tests {
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
zoom: 1.,
|
||||
@@ -2642,6 +2663,7 @@ mod tests {
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected,
|
||||
zoom: 1.,
|
||||
|
||||
@@ -54,6 +54,7 @@ fn bounds() -> Rectangle {
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
|
||||
@@ -62,6 +62,7 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom,
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::*;
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project,
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: Id::nil(),
|
||||
zoom,
|
||||
|
||||
@@ -67,7 +67,10 @@ impl canvas::Program<Message> for PortLabels<'_> {
|
||||
.as_ref()
|
||||
.is_none_or(|(context, _)| *context != canvas.context)
|
||||
{
|
||||
*cached = Some((canvas.context, project_view(canvas.project, canvas.diagram)));
|
||||
*cached = Some((
|
||||
canvas.context,
|
||||
canvas.projected(&canvas.project.diagrams[&canvas.diagram]),
|
||||
));
|
||||
}
|
||||
let view = &cached.as_ref().unwrap().1;
|
||||
let Some(port) = cursor
|
||||
@@ -172,6 +175,7 @@ mod tests {
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use super::*;
|
||||
use oa_core::view_options::{ExchangeLabels, RelationKinds};
|
||||
|
||||
#[test]
|
||||
fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Station", None)
|
||||
.unwrap();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Data & status", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::CommunicationMean, "Radio <link>", a, b);
|
||||
let RelationBody::CommunicationMean { items, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
items.push(item);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
p.place(diagram, a, Position { x: 50., y: 80. });
|
||||
p.place(diagram, b, Position { x: 650., y: 80. });
|
||||
validate_candidate(&p).unwrap();
|
||||
let initial = p.clone();
|
||||
let path_key = format!("<path data-edge=\"{edge}\"");
|
||||
let svg = export_svg(&p, diagram);
|
||||
let edge_path = svg
|
||||
.split(&path_key)
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("</path>")
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(!edge_path.contains("marker-end"));
|
||||
assert!(svg.contains("Radio <link>"));
|
||||
assert_eq!(
|
||||
p, initial,
|
||||
"Export must not author presentation or model changes"
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.exchange_labels = ExchangeLabels::Items;
|
||||
let items_svg = export_svg(&p, diagram);
|
||||
assert!(items_svg.contains("Data & status"));
|
||||
let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
*oriented = true;
|
||||
let svg = export_svg(&p, diagram);
|
||||
assert!(
|
||||
svg.split(&path_key)
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split("</path>")
|
||||
.next()
|
||||
.unwrap()
|
||||
.contains("marker-end")
|
||||
);
|
||||
let placements = p.diagrams[&diagram].nodes.clone();
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.clear();
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert_eq!(p.diagrams[&diagram].nodes, placements);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
@@ -97,6 +97,7 @@ pub struct Document {
|
||||
current_bytes: usize,
|
||||
saved: Arc<SourceBundle>,
|
||||
revision: u64,
|
||||
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
|
||||
limits: ProjectLimits,
|
||||
recovered_unsaved: bool,
|
||||
}
|
||||
@@ -126,6 +127,7 @@ impl Document {
|
||||
future: Vec::new(),
|
||||
saved: Arc::new(bundle),
|
||||
revision: 0,
|
||||
view_index: Default::default(),
|
||||
limits,
|
||||
recovered_unsaved: false,
|
||||
})
|
||||
@@ -135,6 +137,12 @@ impl Document {
|
||||
&self.current.decoded.project
|
||||
}
|
||||
|
||||
/// Semantic reachability is shared by every diagram at this accepted revision.
|
||||
pub fn view_index(&self) -> &oa_core::view_options::ViewIndex {
|
||||
self.view_index
|
||||
.get_or_init(|| oa_core::view_options::ViewIndex::new(self.project()))
|
||||
}
|
||||
|
||||
pub fn bundle(&self) -> &SourceBundle {
|
||||
&self.current.bundle
|
||||
}
|
||||
@@ -220,6 +228,7 @@ impl Document {
|
||||
// Source-position readers must reject their old revision even though
|
||||
// changing the save location does not create an Undo transaction.
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -349,6 +358,7 @@ impl Document {
|
||||
bytes: std::mem::replace(&mut self.current_bytes, previous.bytes),
|
||||
});
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -361,6 +371,7 @@ impl Document {
|
||||
bytes: std::mem::replace(&mut self.current_bytes, next.bytes),
|
||||
});
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
true
|
||||
}
|
||||
|
||||
@@ -382,6 +393,7 @@ impl Document {
|
||||
retained = retained.saturating_sub(self.past.remove(0).bytes);
|
||||
}
|
||||
self.revision = self.revision.saturating_add(1);
|
||||
self.view_index.take();
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -399,6 +411,37 @@ mod tests {
|
||||
Document::from_project(Project::empty(), ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_view_index_tracks_accepted_creation_undo_and_redo() {
|
||||
use oa_core::{Relation, RelationBody, RelationKind, view_options::ViewOptions};
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Team", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Dispatcher", None)
|
||||
.unwrap();
|
||||
let mut document = Document::from_project(p, ProjectLimits::default()).unwrap();
|
||||
let id = Id::new_v4();
|
||||
let options = ViewOptions::default();
|
||||
let visible = |d: &Document| d.view_index().prepare(Layer::Oa, &options).includes(id);
|
||||
assert!(!visible(&document));
|
||||
document
|
||||
.execute(Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: "radio".into(),
|
||||
name: "Radio".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, source, target),
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(visible(&document));
|
||||
assert!(document.undo());
|
||||
assert!(!visible(&document));
|
||||
assert!(document.redo());
|
||||
assert!(visible(&document));
|
||||
}
|
||||
|
||||
fn comment(bundle: &SourceBundle, text: &str) -> SourceBundle {
|
||||
let mut changed = bundle.clone();
|
||||
changed
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
mod view_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordKind {
|
||||
@@ -37,6 +38,11 @@ pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ReferenceBrowser {
|
||||
pub query: String,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
@@ -50,6 +56,7 @@ pub struct Draft {
|
||||
constructor_drafts: BTreeMap<(String, String), (Value, BTreeMap<String, Input>)>,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -171,6 +178,7 @@ impl Draft {
|
||||
constructor_drafts: BTreeMap::new(),
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
reference_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
@@ -653,7 +661,7 @@ fn friendly(name: &str) -> String {
|
||||
"incoming_items" => "Incoming exchange items".into(),
|
||||
"outgoing_items" => "Outgoing exchange items".into(),
|
||||
"orientation" => "Flow orientation".into(),
|
||||
"oriented" => "Directed component exchange".into(),
|
||||
"oriented" => "Directed exchange".into(),
|
||||
"Unset" => "Not specified".into(),
|
||||
"minimum" => "Minimum count".into(),
|
||||
"maximum" => "Maximum count".into(),
|
||||
@@ -986,6 +994,7 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
_ => matches!(
|
||||
r.kind(),
|
||||
RelationKind::Interaction
|
||||
| RelationKind::CommunicationMean
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
),
|
||||
@@ -1656,6 +1665,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
|
||||
"baseline",
|
||||
"layer",
|
||||
"projection",
|
||||
"view_options",
|
||||
"element",
|
||||
"diagram",
|
||||
"position",
|
||||
@@ -1701,6 +1711,9 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = view_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
if let Some(view) = data_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
@@ -2563,6 +2576,7 @@ mod tests {
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -519,11 +519,12 @@ mod tests {
|
||||
};
|
||||
state
|
||||
}
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::Event) {
|
||||
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) {
|
||||
let event = semantic_state(app).address_event(event);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
}
|
||||
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
|
||||
use crate::semantic_review::Event;
|
||||
use crate::semantic_review::RowEvent as Event;
|
||||
semantic_send(app, Event::Select(FacetSelector::Field(facet)));
|
||||
semantic_send(
|
||||
app,
|
||||
@@ -548,7 +549,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn semantic_reference_choices_follow_selected_mechanism_and_revert_on_keep() {
|
||||
use crate::semantic_review::{Event, State};
|
||||
use crate::semantic_review::{RowEvent as Event, State};
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Source", None)
|
||||
@@ -610,7 +611,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn independent_review_combined_take_type_informs_actual_default_form_in_both_directions() {
|
||||
use crate::semantic_review::Event;
|
||||
use crate::semantic_review::RowEvent as Event;
|
||||
for reverse in [false, true] {
|
||||
for decision in [SemanticDecision::TakeSource, SemanticDecision::Candidate] {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
//! Closed view intent and named category membership through ordinary property drafts.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use oa_core::view_options::{
|
||||
self, CategorySelection, ContextSelection, Coverage, EdgeAvailability, EdgeKind,
|
||||
ExchangeFamily, ExchangeLabels, OmissionReason, RelationKinds, ViewIndex, ViewOptions,
|
||||
};
|
||||
|
||||
const PAGE_SIZE: usize = 24;
|
||||
|
||||
struct Candidate {
|
||||
id: Id,
|
||||
label: String,
|
||||
detail: String,
|
||||
}
|
||||
|
||||
fn choice<'a>(
|
||||
label: &str,
|
||||
path: &str,
|
||||
selected: &'static str,
|
||||
choices: Vec<(&'static str, &'static str)>,
|
||||
) -> View<'a, Message> {
|
||||
let path = path.to_owned();
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|(_, value)| *value == selected)
|
||||
.map(|(label, _)| *label);
|
||||
let labels: Vec<_> = choices.iter().map(|(label, _)| *label).collect();
|
||||
column![
|
||||
text(label.to_owned()).size(12).color(style::MUTED),
|
||||
pick_list(labels, selected, move |label| Message::FieldShape(
|
||||
path.clone(),
|
||||
json!(
|
||||
choices
|
||||
.iter()
|
||||
.find(|(candidate, _)| *candidate == label)
|
||||
.unwrap()
|
||||
.1
|
||||
)
|
||||
))
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn named(project: &Project, id: Id, detail: impl Into<String>) -> Candidate {
|
||||
let alias = project
|
||||
.elements
|
||||
.get(&id)
|
||||
.map(|element| element.alias.as_str())
|
||||
.or_else(|| {
|
||||
project
|
||||
.relations
|
||||
.get(&id)
|
||||
.map(|relation| relation.alias.as_str())
|
||||
})
|
||||
.or_else(|| project.traces.get(&id).map(|trace| trace.alias.as_str()))
|
||||
.unwrap_or_default();
|
||||
Candidate {
|
||||
id,
|
||||
label: format!("{} · {}", project.named(id), alias),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn list_value(ids: &[Id], wrapper: Option<&str>) -> Value {
|
||||
match wrapper {
|
||||
Some(wrapper) => json!({wrapper: ids}),
|
||||
None => json!(ids),
|
||||
}
|
||||
}
|
||||
|
||||
fn members<'a>(
|
||||
draft: &'a Draft,
|
||||
path: &str,
|
||||
selected: &[Id],
|
||||
mut candidates: Vec<Candidate>,
|
||||
wrapper: Option<&'static str>,
|
||||
verb: &'static str,
|
||||
) -> View<'a, Message> {
|
||||
// Preserve and expose now-ineligible identities until the engineer removes them.
|
||||
for id in selected {
|
||||
if !candidates.iter().any(|candidate| candidate.id == *id) {
|
||||
candidates.push(Candidate {
|
||||
id: *id,
|
||||
label: format!("Retained reference · {id}"),
|
||||
detail:
|
||||
"Missing or outside the chosen layer/family. Remove or correct before Apply."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
candidates.sort_by_key(|candidate| (candidate.label.to_lowercase(), candidate.id));
|
||||
let browser = draft.reference_browsers.get(path);
|
||||
let query = browser.map(|browser| browser.query.as_str()).unwrap_or("");
|
||||
let search = query.to_lowercase();
|
||||
candidates.retain(|candidate| {
|
||||
format!("{} {} {}", candidate.label, candidate.id, candidate.detail)
|
||||
.to_lowercase()
|
||||
.contains(&search)
|
||||
});
|
||||
let page = browser
|
||||
.map(|browser| browser.page)
|
||||
.unwrap_or(0)
|
||||
.min(candidates.len().saturating_sub(1) / PAGE_SIZE);
|
||||
let start = page * PAGE_SIZE;
|
||||
let path_owned = path.to_owned();
|
||||
let mut out = column![
|
||||
text_input("Find by name, alias, identity or reason", query)
|
||||
.on_input(move |query| Message::FieldReferenceBrowse(path_owned.clone(), query, 0))
|
||||
.size(12)
|
||||
.padding(7),
|
||||
text(format!(
|
||||
"{} {} · {} matching",
|
||||
selected.len(),
|
||||
if verb == "Hide" {
|
||||
"explicitly hidden"
|
||||
} else {
|
||||
"selected"
|
||||
},
|
||||
candidates.len()
|
||||
))
|
||||
.size(11)
|
||||
.color(style::MUTED)
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Fill);
|
||||
if candidates.is_empty() {
|
||||
out = out.push(text("No matching records.").size(12));
|
||||
}
|
||||
for candidate in candidates.iter().skip(start).take(PAGE_SIZE) {
|
||||
let checked = selected.contains(&candidate.id);
|
||||
let id = candidate.id;
|
||||
let selected = selected.to_vec();
|
||||
let path = path.to_owned();
|
||||
out = out.push(
|
||||
column![
|
||||
checkbox(checked)
|
||||
.label(format!("{verb} {}", candidate.label))
|
||||
.size(14)
|
||||
.text_size(12)
|
||||
.on_toggle(move |enabled| {
|
||||
let mut ids = selected.clone();
|
||||
ids.retain(|existing| *existing != id);
|
||||
if enabled {
|
||||
ids.push(id);
|
||||
}
|
||||
Message::FieldShape(path.clone(), list_value(&ids, wrapper))
|
||||
}),
|
||||
text(candidate.detail.clone()).size(11).color(style::MUTED)
|
||||
]
|
||||
.spacing(3)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if candidates.len() > PAGE_SIZE {
|
||||
let mut previous = button(text("Previous").size(11)).style(style::nav);
|
||||
if page > 0 {
|
||||
previous = previous.on_press(Message::FieldReferenceBrowse(
|
||||
path.into(),
|
||||
query.into(),
|
||||
page - 1,
|
||||
));
|
||||
}
|
||||
let mut next = button(text("Next").size(11)).style(style::nav);
|
||||
if start + PAGE_SIZE < candidates.len() {
|
||||
next = next.on_press(Message::FieldReferenceBrowse(
|
||||
path.into(),
|
||||
query.into(),
|
||||
page + 1,
|
||||
));
|
||||
}
|
||||
out = out.push(
|
||||
row![
|
||||
previous,
|
||||
text(format!(
|
||||
"Page {} of {}",
|
||||
page + 1,
|
||||
candidates.len().div_ceil(PAGE_SIZE)
|
||||
))
|
||||
.size(11),
|
||||
next
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn categories<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &Project,
|
||||
layer: Layer,
|
||||
path: &str,
|
||||
family: ExchangeFamily,
|
||||
selection: &CategorySelection,
|
||||
cross_layer: bool,
|
||||
) -> View<'a, Message> {
|
||||
let label = match family {
|
||||
ExchangeFamily::Work => "Work exchange categories",
|
||||
ExchangeFamily::Participant => "Participant exchange categories",
|
||||
};
|
||||
let all = matches!(selection, CategorySelection::All);
|
||||
let path_owned = path.to_owned();
|
||||
let mut out = column![
|
||||
text(label).size(13),
|
||||
checkbox(all)
|
||||
.label("All, including uncategorized exchanges")
|
||||
.text_size(12)
|
||||
.on_toggle(move |all| Message::FieldShape(
|
||||
path_owned.clone(),
|
||||
if all {
|
||||
json!(CategorySelection::All)
|
||||
} else {
|
||||
json!(CategorySelection::Any(vec![]))
|
||||
}
|
||||
))
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Fill);
|
||||
if let CategorySelection::Any(selected) = selection {
|
||||
out = out.push(
|
||||
text("Selected categories combine by union. Selecting none shows none of this family.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
let candidates = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|element| {
|
||||
let SharedBody::ExchangeCategory {
|
||||
family: candidate_family,
|
||||
exchanges,
|
||||
} = element.shared()?
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
((cross_layer || element.layer() == layer) && *candidate_family == family).then(
|
||||
|| {
|
||||
named(
|
||||
project,
|
||||
element.id,
|
||||
format!("{} exchange members", exchanges.len()),
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out = out.push(members(
|
||||
draft,
|
||||
path,
|
||||
selected,
|
||||
candidates,
|
||||
Some("Any"),
|
||||
"Include",
|
||||
));
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn reason(reason: OmissionReason) -> &'static str {
|
||||
match reason {
|
||||
OmissionReason::ExplicitHide => "explicitly hidden",
|
||||
OmissionReason::RelationKind => "relationship kind excluded",
|
||||
OmissionReason::Category(ExchangeFamily::Work) => "outside selected work categories",
|
||||
OmissionReason::Category(ExchangeFamily::Participant) => {
|
||||
"outside selected participant categories"
|
||||
}
|
||||
OmissionReason::Coverage => "outside selected global behavior coverage",
|
||||
OmissionReason::WorkContext => "outside selected process, chain or scenario",
|
||||
OmissionReason::OutsideProjection => "outside this projection",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: true,
|
||||
target: true,
|
||||
} => "source and target are not shown",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: true,
|
||||
target: false,
|
||||
} => "source is not shown",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: false,
|
||||
target: true,
|
||||
} => "target is not shown",
|
||||
OmissionReason::EndpointNotShown {
|
||||
source: false,
|
||||
target: false,
|
||||
} => "endpoint placement unavailable",
|
||||
OmissionReason::FoldedInternal => "internal to a collapsed or grouped node",
|
||||
OmissionReason::WrongLayer => "outside this layer",
|
||||
OmissionReason::UnknownEdge => "edge no longer exists",
|
||||
}
|
||||
}
|
||||
|
||||
fn options<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
options: ViewOptions,
|
||||
) -> View<'a, Message> {
|
||||
let diagram = serde_json::from_value::<Diagram>(draft.context_value())
|
||||
.ok()
|
||||
.or_else(|| project.diagrams.get(&draft.original).cloned());
|
||||
let Some(diagram) = diagram else {
|
||||
return text("View options require a saved view.").size(12).into();
|
||||
};
|
||||
let layer = diagram.layer;
|
||||
let cross_layer = diagram.projection == ProjectionKind::Traceability;
|
||||
let kinds_path = format!("{path}/relation_kinds");
|
||||
let all_kinds = matches!(options.relation_kinds, RelationKinds::All);
|
||||
let toggle_path = kinds_path.clone();
|
||||
let mut out = column![text("Saved view visibility").size(15),
|
||||
text("Apply changes to this view. Model relationships, nesting and saved routes remain available.").size(11).color(style::MUTED),
|
||||
choice("Exchange labels", &format!("{path}/exchange_labels"), match options.exchange_labels { ExchangeLabels::Name => "Name", ExchangeLabels::Items => "Items", ExchangeLabels::NameAndItems => "NameAndItems" }, vec![("Names", "Name"), ("Items", "Items"), ("Names and items", "NameAndItems")]),
|
||||
checkbox(all_kinds).label("All relationship line kinds").text_size(12).on_toggle(move |all| Message::FieldShape(toggle_path.clone(), if all { json!(RelationKinds::All) } else { json!(RelationKinds::Only(vec![])) }))].spacing(10).width(Fill);
|
||||
if let RelationKinds::Only(selected) = &options.relation_kinds {
|
||||
out = out.push(text("No selected kinds means no relationship lines. Nesting and state action rows remain.").size(11).color(style::MUTED));
|
||||
for kind in RelationKind::ALL
|
||||
.into_iter()
|
||||
.map(EdgeKind::Relation)
|
||||
.chain([EdgeKind::Trace])
|
||||
{
|
||||
let label = match kind {
|
||||
EdgeKind::Relation(kind) => kind.to_string(),
|
||||
EdgeKind::Trace => "Realization traces".into(),
|
||||
};
|
||||
let checked = selected.contains(&kind);
|
||||
let selected = selected.clone();
|
||||
let path = kinds_path.clone();
|
||||
out = out.push(checkbox(checked).label(label).text_size(12).on_toggle(
|
||||
move |enabled| {
|
||||
let mut kinds = selected.clone();
|
||||
kinds.retain(|existing| *existing != kind);
|
||||
if enabled {
|
||||
kinds.push(kind);
|
||||
}
|
||||
Message::FieldShape(path.clone(), json!(RelationKinds::Only(kinds)))
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
out = out.push(categories(draft, project, layer, &format!("{path}/work_categories"), ExchangeFamily::Work, &options.work_categories, cross_layer))
|
||||
.push(categories(draft, project, layer, &format!("{path}/participant_categories"), ExchangeFamily::Participant, &options.participant_categories, cross_layer))
|
||||
.push(choice("Work exchange coverage across the whole model", &format!("{path}/coverage"), match options.coverage { Coverage::All => "All", Coverage::InBehavior => "InBehavior", Coverage::OutsideBehavior => "OutsideBehavior" }, vec![("All work exchanges", "All"), ("Used in behavior", "InBehavior"), ("Outside behavior", "OutsideBehavior")]))
|
||||
.push(text("Coverage uses explicit exchange occurrences in processes, chains and scenarios, including reuse. It does not infer execution or participant transport coverage.").size(11).color(style::MUTED));
|
||||
let context_path = format!("{path}/work_contexts");
|
||||
let toggle_path = context_path.clone();
|
||||
out = out.push(
|
||||
checkbox(matches!(options.work_contexts, ContextSelection::All))
|
||||
.label("All work contexts")
|
||||
.text_size(12)
|
||||
.on_toggle(move |all| {
|
||||
Message::FieldShape(
|
||||
toggle_path.clone(),
|
||||
if all {
|
||||
json!(ContextSelection::All)
|
||||
} else {
|
||||
json!(ContextSelection::Any(vec![]))
|
||||
},
|
||||
)
|
||||
}),
|
||||
);
|
||||
if let ContextSelection::Any(selected) = &options.work_contexts {
|
||||
let candidates = project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| {
|
||||
(cross_layer || element.layer() == layer)
|
||||
&& matches!(
|
||||
element.kind(),
|
||||
Kind::Process | Kind::FunctionalChain | Kind::Scenario
|
||||
)
|
||||
})
|
||||
.map(|element| named(project, element.id, element.kind().label()))
|
||||
.collect();
|
||||
out = out.push(text("Show work exchanges used by any selected context, intersected with the coverage and category choices above. None selected shows no work exchanges.").size(11).color(style::MUTED))
|
||||
.push(members(draft, &context_path, selected, candidates, Some("Any"), "Include"));
|
||||
}
|
||||
// Both expensive indexes are built once for the panel, never once per edge.
|
||||
let index = ViewIndex::new(project);
|
||||
let filter = if cross_layer {
|
||||
index.prepare_cross_layer(layer, &options)
|
||||
} else {
|
||||
index.prepare(layer, &options)
|
||||
};
|
||||
let availability = projection::edge_availability(project, &diagram);
|
||||
let mut shown = 0usize;
|
||||
let candidates: Vec<_> = project
|
||||
.relations
|
||||
.keys()
|
||||
.chain(project.traces.keys())
|
||||
.filter(|id| {
|
||||
cross_layer || project.layer_of(**id) == Some(layer) || project.traces.contains_key(id)
|
||||
})
|
||||
.map(|id| {
|
||||
let reasons = filter.reasons(
|
||||
*id,
|
||||
availability
|
||||
.get(id)
|
||||
.copied()
|
||||
.unwrap_or(EdgeAvailability::OutsideProjection),
|
||||
);
|
||||
shown += usize::from(reasons.is_empty());
|
||||
named(
|
||||
project,
|
||||
*id,
|
||||
if reasons.is_empty() {
|
||||
"Shown by these settings".into()
|
||||
} else {
|
||||
reasons
|
||||
.into_iter()
|
||||
.map(reason)
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
out = out.push(text("Individual lines and omission reasons").size(13))
|
||||
.push(text(format!("With these settings: {shown} graphical lines shown · {} lines omitted", candidates.len() - shown)).size(11).color(style::MUTED))
|
||||
.push(text("Check Hide to retain an explicit hide. Unchecking removes only that hide; other listed reasons can still omit the line.").size(11).color(style::MUTED))
|
||||
.push(button(text("Clear all explicit hides").size(11)).style(style::nav).on_press(Message::FieldShape(format!("{path}/hidden_edges"), json!([]))))
|
||||
.push(members(draft, &format!("{path}/hidden_edges"), &options.hidden_edges, candidates, None, "Hide"));
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn category_members<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
value: &Value,
|
||||
) -> View<'a, Message> {
|
||||
let context = draft.context_value();
|
||||
let parent = path
|
||||
.rsplit_once('/')
|
||||
.map(|(parent, _)| parent)
|
||||
.unwrap_or_default();
|
||||
let family = context
|
||||
.pointer(&format!("{parent}/family"))
|
||||
.and_then(|value| serde_json::from_value::<ExchangeFamily>(value.clone()).ok())
|
||||
.unwrap_or(ExchangeFamily::Work);
|
||||
let layer = project.layer_of(draft.original).unwrap_or(Layer::Oa);
|
||||
let Ok(selected) = serde_json::from_value::<Vec<Id>>(value.clone()) else {
|
||||
return column![text("The retained member list contains an invalid reference. Clear it explicitly or cancel this property draft before choosing members.").size(12),
|
||||
button(text("Clear invalid member list").size(11)).style(style::nav).on_press(Message::FieldShape(path.into(), json!([])))].spacing(6).width(Fill).into();
|
||||
};
|
||||
let candidates = project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| {
|
||||
view_options::validate_category(project, layer, None, family, &[relation.id]).is_empty()
|
||||
})
|
||||
.map(|relation| {
|
||||
named(
|
||||
project,
|
||||
relation.id,
|
||||
format!(
|
||||
"{} → {} · {}",
|
||||
project.named(relation.source()),
|
||||
project.named(relation.target()),
|
||||
relation.kind()
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
column![text("Exchange members").size(13), text("Membership is a reusable concern. It does not allocate exchanges, change endpoints or imply execution. An empty category is valid.").size(11).color(style::MUTED),
|
||||
members(draft, path, &selected, candidates, None, "Include")].spacing(6).width(Fill).into()
|
||||
}
|
||||
|
||||
pub(super) fn property<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
) -> Option<View<'a, Message>> {
|
||||
if path == "/view_options" {
|
||||
return Some(match serde_json::from_value::<ViewOptions>(value.clone()) {
|
||||
Ok(value) => options(draft, project, path, value),
|
||||
Err(error) => text(format!("View settings are invalid: {error}. The draft remains available for correction in source.")).size(12).into(),
|
||||
});
|
||||
}
|
||||
if path.ends_with("/ExchangeCategory") {
|
||||
return Some(
|
||||
column![
|
||||
choice(
|
||||
"Exchange family",
|
||||
&format!("{path}/family"),
|
||||
if value.get("family").and_then(Value::as_str) == Some("Participant") {
|
||||
"Participant"
|
||||
} else {
|
||||
"Work"
|
||||
},
|
||||
vec![
|
||||
("Work exchanges", "Work"),
|
||||
("Participant exchanges", "Participant")
|
||||
]
|
||||
),
|
||||
category_members(
|
||||
draft,
|
||||
project,
|
||||
&format!("{path}/exchanges"),
|
||||
value.get("exchanges").unwrap_or(&Value::Null)
|
||||
)
|
||||
]
|
||||
.spacing(10)
|
||||
.width(Fill)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if path.contains("/ExchangeCategory/") {
|
||||
if path.ends_with("/family") {
|
||||
return Some(choice(
|
||||
"Exchange family",
|
||||
path,
|
||||
if value.as_str() == Some("Participant") {
|
||||
"Participant"
|
||||
} else {
|
||||
"Work"
|
||||
},
|
||||
vec![
|
||||
("Work exchanges", "Work"),
|
||||
("Participant exchanges", "Participant"),
|
||||
],
|
||||
));
|
||||
}
|
||||
if path.ends_with("/exchanges") {
|
||||
return Some(category_members(draft, project, path, value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selective_view_property_apply_recovery_and_undo_preserve_semantics_and_empty_choices() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Provide", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(RelationKind::Interaction, "Request data", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.place(diagram, a, Position { x: 10., y: 20. });
|
||||
project.place(diagram, b, Position { x: 300., y: 20. });
|
||||
let mut draft = Draft::from_project(&project, diagram).unwrap();
|
||||
draft
|
||||
.shape(
|
||||
"/view_options/work_categories",
|
||||
list_value(&[], Some("Any")),
|
||||
)
|
||||
.unwrap();
|
||||
draft
|
||||
.shape("/view_options/hidden_edges", list_value(&[exchange], None))
|
||||
.unwrap();
|
||||
draft
|
||||
.shape("/view_options/exchange_labels", json!("NameAndItems"))
|
||||
.unwrap();
|
||||
draft.reference_browsers.insert(
|
||||
"/view_options/hidden_edges".into(),
|
||||
ReferenceBrowser {
|
||||
query: "Request".into(),
|
||||
page: 3,
|
||||
},
|
||||
);
|
||||
let restored = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
assert_eq!(
|
||||
restored.materialize().unwrap(),
|
||||
draft.materialize().unwrap()
|
||||
);
|
||||
assert!(restored.reference_browsers.is_empty());
|
||||
let mut session = Session::new(project.clone());
|
||||
session.execute(restored.command().unwrap()).unwrap();
|
||||
let accepted = session.project();
|
||||
assert_eq!(accepted.elements, project.elements);
|
||||
assert_eq!(accepted.relations, project.relations);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].nodes,
|
||||
project.diagrams[&diagram].nodes
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].view_options.work_categories,
|
||||
CategorySelection::Any(vec![])
|
||||
);
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].view_options.hidden_edges,
|
||||
vec![exchange]
|
||||
);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(accepted)).unwrap(),
|
||||
*accepted
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_family_change_retains_members_until_explicit_correction() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Provide", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(RelationKind::Interaction, "Request data", a, b);
|
||||
let id = project
|
||||
.add_element(Kind::ExchangeCategory, Layer::Oa, "Service concern", None)
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: vec![exchange],
|
||||
};
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
let path = "/body/Oa/Shared/ExchangeCategory";
|
||||
draft
|
||||
.shape(&format!("{path}/family"), json!("Participant"))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft
|
||||
.materialize()
|
||||
.unwrap()
|
||||
.pointer(&format!("{path}/exchanges")),
|
||||
Some(&json!([exchange]))
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
let mut restored = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
restored
|
||||
.shape(&format!("{path}/exchanges"), list_value(&[], None))
|
||||
.unwrap();
|
||||
let accepted = execute_batch(&project, &[restored.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
matches!(accepted.elements[&id].shared(), Some(SharedBody::ExchangeCategory { family: ExchangeFamily::Participant, exchanges }) if exchanges.is_empty())
|
||||
);
|
||||
assert_eq!(accepted.relations, project.relations);
|
||||
}
|
||||
}
|
||||
+306
-23
@@ -25,8 +25,10 @@ pub enum Topic {
|
||||
States,
|
||||
Interfaces,
|
||||
Transitions,
|
||||
SemanticReview,
|
||||
Artifacts,
|
||||
Source,
|
||||
ModelChecks,
|
||||
Recovery,
|
||||
Files,
|
||||
Server,
|
||||
@@ -44,8 +46,12 @@ pub enum Event {
|
||||
Topic(Topic),
|
||||
}
|
||||
|
||||
pub(crate) const ARTICLE_SCROLL_ID: &str = "engineering-guide-article";
|
||||
|
||||
impl State {
|
||||
pub fn update(&mut self, event: Event) {
|
||||
/// Returns whether navigation selected a different article.
|
||||
pub fn update(&mut self, event: Event) -> bool {
|
||||
let previous = self.topic;
|
||||
match event {
|
||||
Event::Search(query) => {
|
||||
self.query = query;
|
||||
@@ -62,11 +68,12 @@ impl State {
|
||||
self.query.clear();
|
||||
}
|
||||
}
|
||||
self.topic != previous
|
||||
}
|
||||
}
|
||||
|
||||
impl Topic {
|
||||
const ALL: [Self; 16] = [
|
||||
const ALL: [Self; 18] = [
|
||||
Self::FirstProject,
|
||||
Self::Perspectives,
|
||||
Self::Participants,
|
||||
@@ -78,8 +85,10 @@ impl Topic {
|
||||
Self::States,
|
||||
Self::Interfaces,
|
||||
Self::Transitions,
|
||||
Self::SemanticReview,
|
||||
Self::Artifacts,
|
||||
Self::Source,
|
||||
Self::ModelChecks,
|
||||
Self::Recovery,
|
||||
Self::Files,
|
||||
Self::Server,
|
||||
@@ -98,9 +107,11 @@ impl Topic {
|
||||
Self::States => "States, modes & transitions",
|
||||
Self::Interfaces => "Data types, ports & interfaces",
|
||||
Self::Transitions => "Move between OA & SA",
|
||||
Self::SemanticReview => "Review semantic changes",
|
||||
Self::Artifacts => "Connect engineering evidence",
|
||||
Self::Source => "Textual editing & Git",
|
||||
Self::Recovery => "Checks & error recovery",
|
||||
Self::ModelChecks => "Read model checks",
|
||||
Self::Recovery => "Drafts & crash recovery",
|
||||
Self::Files => "Files & keyboard shortcuts",
|
||||
Self::Server => "Hosted models & REST API",
|
||||
}
|
||||
@@ -113,8 +124,10 @@ impl Topic {
|
||||
"MODEL THE SYSTEM"
|
||||
}
|
||||
Self::Chains | Self::Scenarios | Self::States | Self::Interfaces => "DESCRIBE BEHAVIOR",
|
||||
Self::Transitions | Self::Artifacts | Self::Source => "CONNECT YOUR WORK",
|
||||
Self::Recovery | Self::Files | Self::Server => "REFERENCE",
|
||||
Self::Transitions | Self::SemanticReview | Self::Artifacts | Self::Source => {
|
||||
"CONNECT YOUR WORK"
|
||||
}
|
||||
Self::ModelChecks | Self::Recovery | Self::Files | Self::Server => "REFERENCE",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,13 +190,23 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose Entity under Create element, then + At root. Name it Rescue coordinator and Apply changes. Use Actor for an external participant such as a Vessel in distress.",
|
||||
"Select Rescue coordinator, choose Activity, then + Allocated. Name the new activity Assess incident. It appears inside its performer; an Allocation relationship records that responsibility.",
|
||||
"Add a second activity for another participant. Select an activity, choose + Relationship, select Interaction and choose the compatible target. Name the exchange after what is conveyed.",
|
||||
"Visit Model checks to review unfinished work. Save project writes the accepted model and applied drafts to your chosen file.",
|
||||
"Visit Model checks to review unfinished work. Save all writes the accepted model and applied drafts to your chosen file.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Continue into system design",
|
||||
"Use Create / update counterpart on an operational element to review an OA → SA mapping. In System Analysis, identify the System under study, its external Actors and the Functions for which each is responsible. The transition tutorial explains scope, reuse and review decisions.",
|
||||
),
|
||||
walkthrough(
|
||||
"Build a reviewable OA → SA baseline",
|
||||
&[
|
||||
"State the mission and capability: for example, protect life at sea by coordinating a rescue. Link the mission to the capability and identify the participants that support it.",
|
||||
"Describe the normal operational Process and an exception Scenario using the activities and Interactions already in the architecture. Give the scenario an explicit Context capability and connect the process with Chain involvement.",
|
||||
"Review which operational work the System will support. Create or reuse its SA counterparts, then check the resulting System boundary, external actors, leaf Function allocations and exchanged payloads.",
|
||||
"Refine the system Functional chain, Scenario and State machine. Specify data contracts and entry/do/exit work where they clarify responsibilities. Link requirements, analyses or tests to the particular records they support.",
|
||||
"Read Model checks, review changes through counterpart traces, and inspect both perspectives. Save the complete source project and review its Git diff as one engineering milestone.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Explore a worked model",
|
||||
"Open the coastal rescue example to inspect allocation, decomposition, repeated scenario messages, operating modes, typed interfaces and artifact references. Loading the example uses the normal unsaved-work prompt. It replaces the current project only after that workflow completes.",
|
||||
@@ -194,6 +217,7 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Participants,
|
||||
Topic::Nesting,
|
||||
Topic::Transitions,
|
||||
Topic::ModelChecks,
|
||||
],
|
||||
destination: Some((Page::Diagram, "Open Architecture")),
|
||||
}
|
||||
@@ -264,7 +288,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "A diagram is a view of the model. Its boxes, positions and nesting reveal selected relationships; moving a box does not redefine engineering ownership.",
|
||||
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset",
|
||||
keywords: "containment semantic parent hierarchy nested nesting allocation deployment diagram placement arrange fit fold hide zoom resize routing connection bend waypoint detour label reset selective visibility category categories coverage process context duplicate copy items",
|
||||
sections: &[
|
||||
explain(
|
||||
"Three things to distinguish",
|
||||
@@ -281,7 +305,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Choose and edit the relationship you mean",
|
||||
&[
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective and projection.",
|
||||
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
|
||||
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships is a broader exploration view; OA ↔ SA traceability displays counterparts.",
|
||||
"Select an element and edit Semantic parent to change ownership. Use an Allocation relationship to change responsibility. Apply changes validates the semantic change.",
|
||||
"Drag nodes to arrange the current view, scroll to zoom and use Fit to frame its contents. Arrange changes diagram geometry. Export SVG produces a shareable view.",
|
||||
@@ -292,6 +316,16 @@ fn article(topic: Topic) -> Article {
|
||||
"Keep diagrams readable",
|
||||
"Use separate views for responsibility, decomposition and cross-layer coverage. Place existing elements with Show in this diagram. A placement's Diagram field can relocate it while preserving its identity and artifact references. Do not duplicate a semantic element merely to show it in another view.",
|
||||
),
|
||||
walkthrough(
|
||||
"Save a focused exchange view",
|
||||
&[
|
||||
"To preserve the current presentation, right-click empty diagram space and choose Duplicate view. The copy has independent placements, routes and visibility settings while representing the same model elements and relationships. View-specific artifact links stay with the original view.",
|
||||
"Open View properties. Under Work exchange categories or Participant exchange categories, uncheck All, including uncategorized exchanges, then Include the categories you need. Selected categories combine by union; none selected shows none of that family. Work means OA Interactions or SA Functional exchanges; participant means OA Communication means or SA Boundary exchanges, including supported legacy records.",
|
||||
"Work exchange coverage across the whole model offers All work exchanges, Used in behavior and Outside behavior. This uses explicit process, chain and scenario occurrences, including reuse. To focus on particular behavior, uncheck All work contexts and Include those processes, chains or scenarios. Context and coverage intersect with categories; no selected contexts means no work lines.",
|
||||
"Choose Exchange labels: Names, Items or Names and items. In Individual lines and omission reasons, search a name or alias and check Hide to retain an explicit hide. Unchecking Hide removes only that reason; other settings or missing endpoints may still omit the line. Clear all explicit hides removes the manual hide list.",
|
||||
"Apply changes and inspect the result. To restore all eligible lines, enable All relationship line kinds, both category All choices and All work contexts, choose All work exchanges, and clear explicit hides. Empty relationship-kind selection shows no lines. These settings do not remove nodes or model facts; a line still needs endpoints eligible for the projection.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Read allocation summaries and ports",
|
||||
"A dashed parent activity/function with an italic name and SUMMARY heading groups leaves assigned to the same performer. It has no direct allocation of its own; its inspector explains the leaf performer. Small ports belong to the boundary of their visible owner and are selectable model elements. Folding a container hides its inner work and ports; summary exchange lines do not invent new ports on the container.",
|
||||
@@ -341,7 +375,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Use scenarios as concrete evidence",
|
||||
"A scenario illustrates one path through the behavior: normal response, degraded operation or an exception. Keep its intended capability and assumptions clear in its engineering description and available model relationships. Scenarios complement the capability definition; they do not replace the outcome.",
|
||||
"A scenario illustrates normal response, degraded operation or an exception. In its Properties, choose Context to name the capability it supports in the same perspective; record assumptions and pre/postconditions too. Connect a Process or Functional chain with Chain involvement. Descriptive prose explains the intent, while these explicit references make the context inspectable.",
|
||||
),
|
||||
explain(
|
||||
"Avoid implementation names at this stage",
|
||||
@@ -362,7 +396,7 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "An exchange says what passes between work or across a participant boundary. Its payload, endpoints and allocation make that statement precise.",
|
||||
keywords: "interaction functional exchange boundary exchange flow information material energy event source target port exchange allocation",
|
||||
keywords: "interaction functional exchange boundary exchange communication mean participant directed bidirectional category categories flow information material energy event source target port exchange allocation",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Create a typed exchange",
|
||||
@@ -371,7 +405,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
|
||||
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
|
||||
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
"In System Analysis, choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -382,6 +416,14 @@ fn article(topic: Topic) -> Article {
|
||||
"Use engineering payloads",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
|
||||
),
|
||||
explain(
|
||||
"Operational and system boundaries",
|
||||
"An OA Interaction connects activities. A Communication mean connects operational Entities or Actors directly; it requires no system ports. In Operational Analysis, choose Communication means and connect its participants. Its properties hold carried items and direction; the relationship form offers Directed source → target (unchecked: bidirectional). Existing OA Boundary exchanges with Legacy ports remain editable for compatibility.",
|
||||
),
|
||||
explain(
|
||||
"Relate operational transport and concerns",
|
||||
"Use Exchange allocation to associate a Communication mean with the Interactions it carries. Allocate each activity to its actual performer and check that those performers match the communication endpoints and direction. Create an Exchange category at the perspective root, choose Work exchanges or Participant exchanges in its properties, and Include existing exchange members. Categories name reusable concerns for view filtering; they do not change endpoints, allocate work or imply execution. A category may be empty, and an exchange may belong to several categories.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Interfaces,
|
||||
@@ -404,8 +446,9 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"First create the activities/functions and their exchanges. Check the source and target work for every exchange.",
|
||||
"Open Behavior & scenarios. Create Process in OA or Functional chain in SA using Create element and + At root.",
|
||||
"Select that behavior in the page's selector. Use + Work or + Exchange to involve an existing source. Each contribution keeps its own identity while referring to the shared engineering definition.",
|
||||
"Select a contribution, then add another after it or use Connect to wire existing contributions. Branch / parallel creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.",
|
||||
"Select that behavior in the page's selector. 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.",
|
||||
"Choose Sequence link and click or drag from the preceding contribution to the following contribution. In Select / reorder, drag a selected path endpoint to 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. Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions; use Fit and zoom to inspect the topology.",
|
||||
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
|
||||
],
|
||||
),
|
||||
@@ -415,7 +458,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Review the path as an engineer",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. Imported ordered exchange paths retain their meaning; the first topology edit explains and preserves their implied links in the same transaction.",
|
||||
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Exchanges, Topic::Capabilities, Topic::Scenarios],
|
||||
@@ -436,7 +479,8 @@ fn article(topic: Topic) -> Article {
|
||||
"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.",
|
||||
"Choose + Message and its style. Call + correlated reply creates both occurrences in one edit. Select the exchange and compatible sending/receiving lifelines. Reply to existing call offers valid unanswered calls.",
|
||||
"Use Action, Observe state or Fragment for other behavior. 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.",
|
||||
"Use the ordered list's up/down controls to change sequence order. Click a message or list entry to inspect that occurrence rather than the reusable exchange definition.",
|
||||
"Choose a message style and Draw message. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. The form retains the chosen position while you select the exchange and details. Escape cancels the gesture.",
|
||||
"In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
@@ -452,6 +496,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Conditions are engineering statements",
|
||||
"Guards, actions and effects document behavior; a drawn scenario does not execute code or prove timing. Add evidence references when a simulation, test or analysis establishes a stronger claim.",
|
||||
),
|
||||
explain(
|
||||
"Keep uses distinct from definitions",
|
||||
"Several occurrences can refer to one exchange without duplicating it. Edit occurrence properties for this message's sender, receiver and value expressions; edit the exchange for the shared endpoints and payload. A Reply refers to a particular Call, not merely a similar name. Reordering across fragment operands or changing lifeline bindings needs an explicit structural edit, not an accidental drag across a boundary.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Chains,
|
||||
@@ -489,7 +537,16 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Availability and state actions",
|
||||
"Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to an Activity/Function and records its entry, do or exit phase. Use these typed relationships when work depends on operating conditions.",
|
||||
"Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to existing Activity/Function work and records Entry, Do or Exit. The saved state view shows those named action rows above internal transitions and nested regions. Select a row to edit its actual State action relationship. The linked work remains in its own semantic owner; the row does not contain or copy it. Free-text entry/do/exit notes remain separate engineering prose.",
|
||||
),
|
||||
walkthrough(
|
||||
"Add timing without losing an existing transition",
|
||||
&[
|
||||
"Select the transition and choose Edit transition. Its current endpoints, kind, existing triggers, guard and effects populate the form.",
|
||||
"Choose Add a time event with this transition, select After for a delay or At for a time, then enter a time expression such as 30 s. This adds a typed Event to the trigger alternatives; it does not replace existing triggers.",
|
||||
"Review the complete transition before Apply. The event and transition update are one undoable change. If the form is rejected, correct the retained values; a rejected candidate creates no orphan Event.",
|
||||
"Save the entry and any included source files. On reopening, check the transition's trigger list and the Event expression. An expression documents timing intent; it is not evaluated by a simulator.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Keep conditions testable",
|
||||
@@ -579,7 +636,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Know what reconciliation changes",
|
||||
"Semantic review supports explicit structural and scalar changes, typed constructors and individual collection rows, including members, literals, annotations and bindings. Candidate validation checks the complete proposed model before applying one undoable transaction. Ordered collections, attachment/artifact and multi-source review controls remain unfinished; some structured facts are read-only. Do not assume Refresh preview automatically synchronizes arbitrary structure or every field.",
|
||||
"Semantic review supports structural and scalar changes, typed constructors, individual collection rows and explicit ordered collections. Several trace rows can contribute to one combined destination proposal. Candidate validation checks the complete proposed model before one undoable transaction. The Review semantic changes topic explains included rows, mapped references and order decisions. Attached-record and artifact reconciliation remain outside this dialog's authoring controls; use their supported editing workflows.",
|
||||
),
|
||||
explain(
|
||||
"Avoid duplicate counterparts",
|
||||
@@ -591,6 +648,68 @@ fn article(topic: Topic) -> Article {
|
||||
Topic::Participants,
|
||||
Topic::Capabilities,
|
||||
Topic::Recovery,
|
||||
Topic::SemanticReview,
|
||||
],
|
||||
destination: Some((Page::Traces, "Open Traceability")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::SemanticReview => {
|
||||
const {
|
||||
Article {
|
||||
summary: "Review what changed between corresponding OA and SA records. Choose the engineering result explicitly and retain the reasoned correspondence for later changes.",
|
||||
keywords: "semantic drift review keep take final candidate previously reviewed history reset reference pairs annotation qualified field path binding lifeline membership order up down reconcile steps fragment branch direction multi-source batch",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Compare the right records and direction",
|
||||
&[
|
||||
"From an existing counterpart trace, choose Review semantics…. Confirm the OA → SA or SA → OA direction and select the intended trace / review history. Each correspondence and direction has its own retained row draft; opening another row does not discard the first row's choices.",
|
||||
"In Properties, select a fact. Compare Previously reviewed with Current on both sides. Unreviewed means there is no prior observation; Both changed means both sides changed since that observation, not that one side is automatically wrong.",
|
||||
"Choose Keep for the destination, Take for the source, or Set final value…. For a kind change, Set final kind and payload… reviews the complete replacement and its affected references. Leave unselected makes no decision for that fact.",
|
||||
"Read Reference pairs and Additional changes before Apply reviewed changes. A missing or ambiguous correspondence must be resolved explicitly. Apply commits the validated model and the reviewed history together; Undo restores both.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Map references by meaning",
|
||||
"Reference pairs connect exact OA and SA identities used by the proposed change. Similar names are not sufficient. Map the records that play the same engineering role, including enclosing fields in qualified message-value paths. Mapping a referenced record does not authorize creating, moving or editing that record. If it needs a counterpart, establish that correspondence first.",
|
||||
),
|
||||
walkthrough(
|
||||
"Review one message value or lifeline binding",
|
||||
&[
|
||||
"Open Members / rows and choose the exact identities on both sides. For a message value, browse the enclosing record fields as well as the final field: origin.latitude and destination.latitude are distinct addresses.",
|
||||
"For a binding, choose the referenced lifeline key and its compatible local lifeline. Check which scenario owns each side. Keep or Take concerns that row, not all bindings in the scenario.",
|
||||
"Use Set final value… to enter the destination expression or local lifeline. Include in destination collection controls that row's presence. Unchecking removes the entry without deleting its referenced engineering object.",
|
||||
"If a row key changed, review the old entry's removal and the new entry's addition separately. A missing source identity is not the same decision as an identified source entry that has been deleted. Read the proposed value before applying.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Review membership and sequence order separately",
|
||||
&[
|
||||
"Review which occurrences belong to Steps, or which operands belong to Fragment branches, using Members / rows. This changes membership; it does not silently reparent or create the referenced records.",
|
||||
"Choose Review final order… or the corresponding order fact in Properties. Keep retains destination positions. Take uses mapped source order when it can preserve destination-only members without an ambiguous placement.",
|
||||
"For an explicit result, choose Set final value…, then use Up and Down. If you change membership afterward, choose Reconcile with selected membership: surviving positions are retained, removed members leave the list, and new members append for you to position.",
|
||||
"Every surviving destination member must appear exactly once. Resolve an ambiguous insertion with an explicit final order; do not remove an engineering contribution merely to make the preview pass.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Preserve intentional differences and useful history",
|
||||
"Keep can acknowledge a deliberate difference without overwriting the destination. Reset this exact previous observation clears only the chosen review history; it does not restore an old model value. Overlapping row addresses may require an explicit history reset. Review those messages rather than clearing unrelated observations. Pending review choices and raw values can be recovered after an unexpected exit, but the plan is recomputed against the recovered accepted model.",
|
||||
),
|
||||
walkthrough(
|
||||
"Review several sources together",
|
||||
&[
|
||||
"Use Choose another trace to add or reopen, then Open row. Include this row determines whether its decisions contribute to the shared preview. The trace / review history selector switches the row you are editing; excluded rows retain their drafts.",
|
||||
"Review each included source's contribution to the destination. Compatible decisions combine; contradictory Keep, Take or final-value decisions must be resolved explicitly. Read the combined conflicts, Reference pairs and Additional changes before Apply reviewed changes. One Apply commits the combined model and review histories; one Undo restores them.",
|
||||
"Only one trace history per source can be included. Some reverse correspondences cannot share the core reference mapping; the preview explains the conflict. Exclude a row or choose a distinct correspondence without discarding its draft. Switching direction retains the other direction's choices for later review.",
|
||||
"Recovery retains included and excluded row drafts and raw final values, then recomputes the plan against the recovered accepted model. Refresh preview is not automatic synchronization and does not authorize changes to attached records or artifacts.",
|
||||
],
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Transitions,
|
||||
Topic::Interfaces,
|
||||
Topic::Scenarios,
|
||||
Topic::Recovery,
|
||||
],
|
||||
destination: Some((Page::Traces, "Open Traceability")),
|
||||
}
|
||||
@@ -608,7 +727,7 @@ fn article(topic: Topic) -> Article {
|
||||
"Select the model record the evidence concerns. This may be an element, relationship, diagram, placement or other supported persistent record.",
|
||||
"Choose + Artifact reference in Properties, or + Artifact in Traceability. Name the reference so another engineer understands its purpose.",
|
||||
"Enter the Artifact URI, optional external Revision and Fragment. Use a commit or document revision when a reproducible reference matters; the fragment identifies a particular part of the artifact.",
|
||||
"Add a description explaining whether the artifact specifies, implements or verifies the engineering decision. Apply changes, then inspect the owner and reference in Traceability.",
|
||||
"Choose the relation to the artifact: References, Satisfies, Verifies or Implements. Add a description explaining the evidence and its limits. Apply changes, then inspect the owner and reference in Traceability.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -659,6 +778,11 @@ fn article(topic: Topic) -> Article {
|
||||
"Use one contract",
|
||||
"The normative specification is docs/language.rst in the distribution. It defines source grammar and supported graphical semantics together. Text preserves semantic records, diagram placements, ordered occurrences, traces and artifact references. Graphical edits preserve unaffected source formatting and comments; accepted model Undo/Redo also restores the exact source files. Comments and source-file organization are not part of JSON export.",
|
||||
),
|
||||
example(
|
||||
"Keep included files with their entry",
|
||||
"An include is relative to the file that declares it. Put declarations in the included file and keep one project entry. A newly created graphical record may live in another source file; use Open source to locate it and include every affected file in your review and save.",
|
||||
"// Inside the project declaration:\ninclude \"oa/operations.arcadia\";\ninclude \"sa/model.arcadia\";",
|
||||
),
|
||||
explain(
|
||||
"Resolve source-control conflicts before applying",
|
||||
"Ordinary Git conflict markers are not Arcadia syntax. Resolve the text merge, preserve intended UUIDs/references, then apply and inspect Model checks. The editor does not decide which engineer's model change is correct.",
|
||||
@@ -674,6 +798,53 @@ fn article(topic: Topic) -> Article {
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::ModelChecks => {
|
||||
const {
|
||||
Article {
|
||||
summary: "Use checks to find invalid structure and unfinished engineering work. A valid model can still need decisions, behavior evidence or allocation.",
|
||||
keywords: "validation warning error completeness coverage non_leaf_work_exchange work_without_behavior_context exchange_without_behavior_context mission_without_capability capability_without_behavior_context unsupported_capability sequence_gap allocation leaf context diagnostics lint",
|
||||
sections: &[
|
||||
explain(
|
||||
"Errors and warnings answer different questions",
|
||||
"An error rejects the candidate, leaving the accepted model unchanged. A warning describes an incomplete or questionable model that can still be saved. Read the record, field and source location rather than treating the number of warnings as a quality score. Fix structural errors first: the completeness pass waits for valid structure, so its warnings may appear after those errors are corrected.",
|
||||
),
|
||||
walkthrough(
|
||||
"Connect work to explicit behavior",
|
||||
&[
|
||||
"For an exchange on non-leaf work, identify the leaf Activity or Function responsible for that endpoint. In SA, keep its typed input/output port owned by that Function. Do not add a fake child just to suppress the warning.",
|
||||
"For leaf work without behavior context, include a work action or one of its exchanges in an ordered Process/Functional chain or Scenario. Allocation alone states who performs the work, not how it is used.",
|
||||
"For an exchange without behavior context, include that exact Interaction or Functional exchange in an exchange occurrence. A similarly named exchange or an unlisted occurrence under the container does not provide evidence.",
|
||||
"Only included steps, fragment operands and valid reused behavior contribute. State actions, availability links and prose remain useful but do not stand in for an explicit process/scenario use. Coverage demonstrates inclusion, not execution or completeness of every possible path.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Connect purpose to behavioral evidence",
|
||||
&[
|
||||
"For a mission without a capability, connect the mission to the capability that delivers it. Merely involving a participant in the mission does not identify that capability.",
|
||||
"For a supported capability without behavior context, connect a Process/Functional chain using Chain involvement or set a Scenario's Context to that capability in the same perspective.",
|
||||
"A capability used only to group child capabilities is treated differently from an isolated leaf. A directly contextualized Scenario establishes support; adding an unrelated participant link is not required merely to clear a warning.",
|
||||
"Review the engineering statement after each correction. Do not invent relationships solely to achieve a warning-free list; leave an unfinished decision visible until the model can express it accurately.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Review sequence gaps and incomplete contracts",
|
||||
"A sequence gap calls attention to disconnected handoffs; inspect the contribution endpoints and intended path. Missing allocations, unspecified exchange mechanisms and incomplete conditions need their own engineering decisions. An accepted model does not prove that guards are executable, units are converted, external artifacts exist or timing requirements are met.",
|
||||
),
|
||||
explain(
|
||||
"The same checks are available to text and automation",
|
||||
"Model checks, source lint and server validation use the shared model rules. Source-project diagnostics identify the included file and location; native JSON checks identify the model record and field. A validation request can inspect a candidate without committing it. Correct errors in the complete candidate before applying it graphically, as text or through the API.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
Topic::Capabilities,
|
||||
Topic::Chains,
|
||||
Topic::Scenarios,
|
||||
Topic::Recovery,
|
||||
],
|
||||
destination: Some((Page::Validation, "Open Model checks")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Topic::Recovery => {
|
||||
const {
|
||||
Article {
|
||||
@@ -721,7 +892,12 @@ fn article(topic: Topic) -> Article {
|
||||
"New, Open, opening a recent model, loading the example, Recover and Close use the unsaved-work prompt. Save and continue, discard or cancel is your decision. Checkpoints are private recovery copies; continue saving meaningful milestones and using version control or backups.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Source, Topic::Files, Topic::Transitions],
|
||||
related: &[
|
||||
Topic::ModelChecks,
|
||||
Topic::Source,
|
||||
Topic::Files,
|
||||
Topic::SemanticReview,
|
||||
],
|
||||
destination: Some((Page::Validation, "Open Model checks")),
|
||||
}
|
||||
}
|
||||
@@ -734,7 +910,7 @@ fn article(topic: Topic) -> Article {
|
||||
sections: &[
|
||||
explain(
|
||||
"Start from Home",
|
||||
"Home offers New model, Open model, the rescue example and this guide. Recent models lists local files you successfully opened or saved. A disconnected drive or moved file remains visible; use Locate to choose its new location or Refresh after reconnecting the drive. Remove forgets the recent entry without deleting its model file. Returning to Home retains the current document and uses the same draft navigation guards as other pages.",
|
||||
"Home offers New model, Open model, the rescue example and this guide. Recent models lists local files you successfully opened or saved. A disconnected drive or moved file remains visible; use Locate to choose its new location or Refresh after reconnecting the drive. Remove forgets the recent entry without deleting its model file. Home retains the current document; Continue editing returns to the last workspace, including Textual model or a saved state view, with its drafts intact.",
|
||||
),
|
||||
explain(
|
||||
"Editable formats",
|
||||
@@ -743,7 +919,7 @@ fn article(topic: Topic) -> Article {
|
||||
example(
|
||||
"Keyboard reference",
|
||||
"Shortcuts act on the current project. In Textual model, undo and redo affect the source draft.",
|
||||
"Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save project\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog",
|
||||
"Ctrl+N New project\nCtrl+O Open model\nCtrl+S Save all\nCtrl+Shift+S Save as\nCtrl+Z Undo\nCtrl+Shift+Z Redo\nCtrl+Y Redo\nEscape Cancel the active dialog",
|
||||
),
|
||||
walkthrough(
|
||||
"Save and share a model",
|
||||
@@ -754,6 +930,10 @@ fn article(topic: Topic) -> Article {
|
||||
"If another tool changed or deleted the open file, preserve your candidate using a new Save as destination. Compare and reconcile the versions before replacing either one.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Save as preserves the engineering history",
|
||||
"Saving a source project under a new entry name relocates the save destination without adding a model edit or clearing Redo. Choose a name that does not collide with an included file, including one retained in Undo/Redo history. Save all includes every accepted source file; an unchanged entry file alone does not prove that an included file was saved. If a save fails, the current model and drafts remain available.",
|
||||
),
|
||||
explain(
|
||||
"Open and migration",
|
||||
"Open validates a complete candidate before replacing the project. Supported earlier versions migrate to the current schema; Model checks shows migration details such as preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
|
||||
@@ -806,11 +986,15 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Create or import a project using /api/v1/projects or /api/v1/projects/import. Keep the returned service resource_id; it is distinct from the editable model UUID.",
|
||||
"Read the project's envelope, source or model. Preserve its exact ETag as the precondition for your edit.",
|
||||
"Submit typed commands to /transactions, or a complete candidate to /source or /model, using If-Match. Use transaction dry-run or validation before committing substantial generated changes.",
|
||||
"Submit typed commands to /transactions, or a complete candidate to /source, /model or /source-project, using If-Match. Use transaction dry-run or validation before committing substantial generated changes.",
|
||||
"A stale write returns 412. Keep the user's draft, reload the current head and reconcile deliberately. After an uncertain write outcome, read the server state before retrying.",
|
||||
"Query /artifact-links and /traces to connect domain-tool artifacts to model records. The server stores these references; the integration is responsible for resolving its external artifacts.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Validate and save a source bundle",
|
||||
"GET /source-project returns the entry and included files. POST /validation with format source-project and source_project containing that bundle checks a candidate without committing it; warnings can coexist with valid: true. PUT /source-project accepts the complete bundle under the current If-Match ETag. Structural errors reject the write, preserving the accepted model and revision. Compare record IDs and fields across text/JSON diagnostics; source-project results also identify the included file and source span.",
|
||||
),
|
||||
explain(
|
||||
"Deployment and persistence",
|
||||
"Non-loopback binding requires a configured bearer token. Use the documented local-disk storage and deployment configuration. The service keeps the current revision and serializes accepted updates; it does not provide retained revision history or server-side undo. Stop the service cleanly before copying its committed store for backup.",
|
||||
@@ -922,6 +1106,13 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
|
||||
);
|
||||
}
|
||||
if topic == Topic::FirstProject {
|
||||
content = content.push(text("For a worked model, open the water-pressure walkthrough. Copy the whole examples/release1 folder and open the copied project.arcadia before editing; read its README.md. If you use Save as, also copy evidence/ beside the new entry: Save as copies model source, not external artifact targets.").size(13));
|
||||
content = content.push(
|
||||
button(text("Open water-pressure walkthrough").size(13))
|
||||
.padding([11, 15])
|
||||
.style(style::nav)
|
||||
.on_press(Message::Home(crate::home::Event::Walkthrough)),
|
||||
);
|
||||
content = content.push(
|
||||
button(text("Open coastal rescue example").size(13))
|
||||
.padding([11, 15])
|
||||
@@ -938,6 +1129,7 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
|
||||
bottom: 24.,
|
||||
..Default::default()
|
||||
}))
|
||||
.id(ARTICLE_SCROLL_ID)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into()
|
||||
@@ -974,7 +1166,7 @@ pub fn view(state: &State) -> Element<'_, Message> {
|
||||
button(text("Clear search").size(13)).padding([10, 14]).on_press(Message::Guide(Event::Search(String::new()))),
|
||||
].spacing(16).max_width(520)).padding(24).width(Fill).into()
|
||||
} else {
|
||||
// A different article gets a fresh scroll state and starts at its title.
|
||||
// Topic changes also issue an explicit article-only scroll reset.
|
||||
keyed_column([(state.topic, article_view(state.topic))])
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
@@ -1022,6 +1214,97 @@ pub fn view(state: &State) -> Element<'_, Message> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ScrollProbe {
|
||||
snaps: Vec<iced::advanced::widget::operation::scrollable::RelativeOffset<Option<f32>>>,
|
||||
}
|
||||
|
||||
impl iced::advanced::widget::operation::Scrollable for ScrollProbe {
|
||||
fn snap_to(
|
||||
&mut self,
|
||||
offset: iced::advanced::widget::operation::scrollable::RelativeOffset<Option<f32>>,
|
||||
) {
|
||||
self.snaps.push(offset);
|
||||
}
|
||||
fn scroll_to(
|
||||
&mut self,
|
||||
_: iced::advanced::widget::operation::scrollable::AbsoluteOffset<Option<f32>>,
|
||||
) {
|
||||
panic!("Guide navigation must snap to the article start");
|
||||
}
|
||||
fn scroll_by(
|
||||
&mut self,
|
||||
_: iced::advanced::widget::operation::scrollable::AbsoluteOffset,
|
||||
_: iced::Rectangle,
|
||||
_: iced::Rectangle,
|
||||
) {
|
||||
panic!("Guide navigation must not use a relative pixel scroll");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guide_topic_navigation_resets_only_article_and_same_topic_search_keeps_scroll() {
|
||||
use iced::futures::StreamExt;
|
||||
let mut app = crate::app::App::with_project(oa_core::Project::empty());
|
||||
app.page = Page::Guide;
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
for (event, topic, reset) in [
|
||||
(Event::Topic(Topic::Scenarios), Topic::Scenarios, true),
|
||||
(
|
||||
Event::Search("sender lifeline".into()),
|
||||
Topic::Scenarios,
|
||||
false,
|
||||
),
|
||||
// Native reproduction: related topic after scrolling Scenarios.
|
||||
(Event::Topic(Topic::Exchanges), Topic::Exchanges, true),
|
||||
(Event::Search("If-Match 412".into()), Topic::Server, true),
|
||||
(Event::Search("If-Match".into()), Topic::Server, false),
|
||||
(
|
||||
Event::Search("nonexistent-topic-987".into()),
|
||||
Topic::Server,
|
||||
false,
|
||||
),
|
||||
(Event::Search(String::new()), Topic::Server, false),
|
||||
(Event::Topic(Topic::Server), Topic::Server, false),
|
||||
] {
|
||||
let mut article = ScrollProbe::default();
|
||||
let mut navigation = ScrollProbe::default();
|
||||
if let Some(mut stream) =
|
||||
iced_runtime::task::into_stream(app.update(Message::Guide(event)))
|
||||
{
|
||||
while let Some(action) = stream.next().await {
|
||||
let iced_runtime::Action::Widget(mut operation) = action else {
|
||||
panic!("Guide navigation must not schedule model or external actions");
|
||||
};
|
||||
for (id, probe) in [
|
||||
(ARTICLE_SCROLL_ID, &mut article),
|
||||
("guide-topic-navigation", &mut navigation),
|
||||
] {
|
||||
operation.scrollable(
|
||||
Some(&iced::advanced::widget::Id::new(id)),
|
||||
iced::Rectangle::default(),
|
||||
iced::Rectangle::default(),
|
||||
iced::Vector::new(0., 900.),
|
||||
probe,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(app.guide.topic, topic);
|
||||
assert_eq!(article.snaps.len(), usize::from(reset));
|
||||
for offset in article.snaps {
|
||||
assert_eq!(offset.x, Some(0.));
|
||||
assert_eq!(offset.y, Some(0.));
|
||||
}
|
||||
assert!(navigation.snaps.is_empty());
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_finds_concepts_in_walkthroughs_and_api_reference() {
|
||||
assert!(search(" ALLOCATED rescue ").contains(&Topic::FirstProject));
|
||||
|
||||
@@ -21,6 +21,7 @@ pub enum Event {
|
||||
New,
|
||||
Open,
|
||||
Example,
|
||||
Walkthrough,
|
||||
Guide,
|
||||
Resume,
|
||||
OpenRecent(PathBuf),
|
||||
@@ -43,6 +44,35 @@ pub enum Availability {
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// Resolve the packaged example independently of the process working directory.
|
||||
pub(crate) fn walkthrough_path() -> Result<PathBuf, String> {
|
||||
let executable = std::env::current_exe().map_err(|error| error.to_string())?;
|
||||
let development =
|
||||
cfg!(debug_assertions).then(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."));
|
||||
resolve_walkthrough(&executable, development.as_deref())
|
||||
}
|
||||
|
||||
fn resolve_walkthrough(executable: &Path, development: Option<&Path>) -> Result<PathBuf, String> {
|
||||
let relative = Path::new("examples/release1/project.arcadia");
|
||||
let packaged = executable
|
||||
.parent()
|
||||
.ok_or("Application directory is unavailable.")?
|
||||
.join(relative);
|
||||
if packaged.is_file() {
|
||||
return Ok(packaged);
|
||||
}
|
||||
if let Some(root) = development {
|
||||
let source = root.join(relative);
|
||||
if source.is_file() {
|
||||
return Ok(source);
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"The water-pressure walkthrough is missing. Restore the complete examples/release1 folder beside the application, then try again. Expected {}.",
|
||||
packaged.display()
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Recent {
|
||||
pub entry: Entry,
|
||||
@@ -311,6 +341,11 @@ pub fn view<'a>(
|
||||
action("Explore rescue example", Event::Example).style(style::nav),
|
||||
].spacing(12),
|
||||
].spacing(18).max_width(940);
|
||||
content = content.push(container(column![
|
||||
text("Restore safe water pressure").size(18),
|
||||
text("Follow an OA → SA walkthrough with processes, scenarios, states and evidence. Copy the whole examples/release1 folder and open its project.arcadia before editing; the tutorial is README.md. Save as copies model source only, so also copy evidence/ to the new folder if you use it.").size(13),
|
||||
action("Open water-pressure walkthrough", Event::Walkthrough).style(style::nav),
|
||||
].spacing(10)).padding(20).style(style::subtle));
|
||||
if let Some(warning) = &recovery.warning {
|
||||
content = content.push(text(warning).size(12).color(style::MUTED));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn walkthrough_uses_packaged_bundle_before_development_and_reports_missing_assets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let packaged = temp
|
||||
.path()
|
||||
.join("application/examples/release1/project.arcadia");
|
||||
let development = temp.path().join("checkout");
|
||||
let checkout_entry = development.join("examples/release1/project.arcadia");
|
||||
let executable = temp.path().join("application/arcadia-studio.exe");
|
||||
fs::create_dir_all(checkout_entry.parent().unwrap()).unwrap();
|
||||
fs::write(&checkout_entry, "development source").unwrap();
|
||||
assert_eq!(
|
||||
resolve_walkthrough(&executable, Some(&development)).unwrap(),
|
||||
checkout_entry
|
||||
);
|
||||
fs::create_dir_all(packaged.parent().unwrap()).unwrap();
|
||||
fs::write(&packaged, "packaged source").unwrap();
|
||||
assert_eq!(
|
||||
resolve_walkthrough(&executable, Some(&development)).unwrap(),
|
||||
packaged
|
||||
);
|
||||
assert_eq!(resolve_walkthrough(&executable, None).unwrap(), packaged);
|
||||
fs::remove_file(&packaged).unwrap();
|
||||
let error = resolve_walkthrough(&executable, None).unwrap_err();
|
||||
assert!(error.contains("complete examples/release1 folder"));
|
||||
assert!(
|
||||
error
|
||||
.replace('\\', "/")
|
||||
.contains(&packaged.display().to_string().replace('\\', "/")),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(checkout_entry).unwrap(),
|
||||
"development source"
|
||||
);
|
||||
}
|
||||
|
||||
fn model(directory: &Path, name: &str) -> PathBuf {
|
||||
let path = directory.join(name);
|
||||
fs::write(&path, b"model contents must remain untouched").unwrap();
|
||||
|
||||
@@ -29,6 +29,7 @@ mod source_workspace;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod view_copy;
|
||||
mod views;
|
||||
mod workflow;
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
if request.canvas.is_some() && app.active_diagram == Some(id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Duplicate view", Message::DuplicateDiagram(id), true),
|
||||
item("Fit diagram", Message::Fit, true),
|
||||
item(
|
||||
"Arrange elements",
|
||||
@@ -444,6 +445,11 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
|
||||
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
|
||||
match kind {
|
||||
Kind::Entity | Kind::Actor => &[(
|
||||
"Configure communication means…",
|
||||
RelationKind::CommunicationMean,
|
||||
false,
|
||||
)],
|
||||
Kind::OperationalMission | Kind::Mission => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
|
||||
@@ -226,6 +226,18 @@ impl State {
|
||||
state.preview = state.plan(p);
|
||||
Ok(state)
|
||||
}
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
p: &Project,
|
||||
legacy_base: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, String> {
|
||||
let mut saved: RecoveryState = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.version != 1 || saved.base != legacy_base {
|
||||
return Err("Port workflow recovery does not match the accepted source-5 model identity and revision.".into());
|
||||
}
|
||||
saved.base = transitions::project_fingerprint(p);
|
||||
Self::from_recovery(p, serde_json::to_value(saved).map_err(|e| e.to_string())?)
|
||||
}
|
||||
pub fn new(p: &Project, subject: Id, action: Action) -> Result<Self, String> {
|
||||
if !actions(p, subject).contains(&action) {
|
||||
return Err("This port action is unavailable for the selected element.".into());
|
||||
@@ -802,6 +814,7 @@ mod tests {
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,6 +14,9 @@ use std::{
|
||||
};
|
||||
|
||||
const MAX_BYTES: u64 = 256 * 1024 * 1024;
|
||||
mod source5;
|
||||
#[cfg(test)]
|
||||
mod source5_tests;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Snapshot {
|
||||
@@ -126,17 +129,26 @@ impl Snapshot {
|
||||
}
|
||||
let mut document =
|
||||
Document::from_bundle(self.accepted.clone(), LIMITS).map_err(|e| e.to_string())?;
|
||||
if document.bundle() != &self.accepted {
|
||||
return Err("Checkpoint accepted source requires migration or identity insertion; it cannot be recovered exactly.".into());
|
||||
let legacy_base = source5::upgrade_binding(&self.accepted, &document)?;
|
||||
let mut saved_workspace = self.workspace;
|
||||
if legacy_base.is_some() {
|
||||
saved_workspace.upgrade_clean_buffers(&self.accepted, document.bundle());
|
||||
}
|
||||
let workspace = Workspace::from_recovery(self.workspace)?;
|
||||
let workspace = Workspace::from_recovery(saved_workspace)?;
|
||||
let property = self
|
||||
.property
|
||||
.map(|s| Draft::from_recovery(document.project(), s))
|
||||
.transpose()?;
|
||||
let workflow = self
|
||||
.workflow
|
||||
.map(|value| crate::workflow::Workflow::from_recovery(document.project(), value))
|
||||
.map(|value| match legacy_base.as_deref() {
|
||||
Some(base) => crate::workflow::Workflow::from_recovery_after_source5_upgrade(
|
||||
document.project(),
|
||||
base,
|
||||
value,
|
||||
),
|
||||
None => crate::workflow::Workflow::from_recovery(document.project(), value),
|
||||
})
|
||||
.transpose()?;
|
||||
let mut behavior = crate::behavior::State::default();
|
||||
if let Some(saved) = self.behavior {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Narrow compatibility for private checkpoints written by the source-5 desktop.
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
Diagram, Project,
|
||||
language::{self, TokenClass},
|
||||
};
|
||||
use serde::ser::{SerializeStruct, Serializer};
|
||||
|
||||
/// Return the historical workflow binding only after proving that migration
|
||||
/// changed precisely the version token, with no identity or semantic insertion.
|
||||
pub(super) fn upgrade_binding(
|
||||
accepted: &SourceBundle,
|
||||
document: &Document,
|
||||
) -> Result<Option<String>, String> {
|
||||
if document.bundle() == accepted {
|
||||
return Ok(None);
|
||||
}
|
||||
let unsupported = || {
|
||||
"Checkpoint accepted source cannot be recovered exactly; only a lossless source 5 to 6 upgrade is supported. The checkpoint has been retained.".to_owned()
|
||||
};
|
||||
let report = document
|
||||
.compiled()
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.ok_or_else(unsupported)?;
|
||||
if (report.from_schema, report.to_schema) != (5, 6) {
|
||||
return Err(unsupported());
|
||||
}
|
||||
let source = accepted
|
||||
.files
|
||||
.get(&accepted.entry)
|
||||
.ok_or_else(unsupported)?;
|
||||
let tokens: Vec<_> = language::tokenize(source)
|
||||
.into_iter()
|
||||
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
|
||||
.take(3)
|
||||
.collect();
|
||||
if tokens.len() != 3
|
||||
|| &source[tokens[0].span.start..tokens[0].span.end] != "arcadia"
|
||||
|| tokens[1].class != TokenClass::Number
|
||||
|| &source[tokens[1].span.start..tokens[1].span.end] != "5"
|
||||
|| &source[tokens[2].span.start..tokens[2].span.end] != ";"
|
||||
{
|
||||
return Err(unsupported());
|
||||
}
|
||||
let mut expected = accepted.clone();
|
||||
expected
|
||||
.files
|
||||
.get_mut(&accepted.entry)
|
||||
.ok_or_else(unsupported)?
|
||||
.replace_range(tokens[1].span.start..tokens[1].span.end, "6");
|
||||
if document.bundle() != &expected {
|
||||
return Err(unsupported());
|
||||
}
|
||||
// The old source admission rejects all source-6 constructs. Exact token-only
|
||||
// migration establishes that borrowed records below retain their old shape.
|
||||
legacy_fingerprint(document.project()).map(Some)
|
||||
}
|
||||
|
||||
fn legacy_fingerprint(p: &Project) -> Result<String, String> {
|
||||
if p.schema_version != 6
|
||||
|| p.diagrams
|
||||
.values()
|
||||
.any(|d| d.view_options != Default::default())
|
||||
{
|
||||
return Err("Checkpoint migration does not have a source-5 model shape.".into());
|
||||
}
|
||||
let bytes = serde_json::to_vec(&Project5(p)).map_err(|e| e.to_string())?;
|
||||
let hash = bytes.iter().fold(0xcbf29ce484222325u64, |hash, byte| {
|
||||
(hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3)
|
||||
});
|
||||
Ok(format!("{hash:016x}"))
|
||||
}
|
||||
|
||||
// Preserve the exact historical serde field order (2b2d2bc), including empty
|
||||
// maps and routes. Serializing through Value would reorder struct fields.
|
||||
struct Project5<'a>(&'a Project);
|
||||
impl Serialize for Project5<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let p = self.0;
|
||||
let diagrams: std::collections::BTreeMap<_, _> =
|
||||
p.diagrams.iter().map(|(id, d)| (id, Diagram5(d))).collect();
|
||||
let mut s = serializer.serialize_struct("Project", 10)?;
|
||||
s.serialize_field("schema_version", &5u32)?;
|
||||
s.serialize_field("id", &p.id)?;
|
||||
s.serialize_field("alias", &p.alias)?;
|
||||
s.serialize_field("name", &p.name)?;
|
||||
s.serialize_field("description", &p.description)?;
|
||||
s.serialize_field("elements", &p.elements)?;
|
||||
s.serialize_field("relations", &p.relations)?;
|
||||
s.serialize_field("diagrams", &diagrams)?;
|
||||
s.serialize_field("artifact_links", &p.artifact_links)?;
|
||||
s.serialize_field("traces", &p.traces)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
struct Diagram5<'a>(&'a Diagram);
|
||||
impl Serialize for Diagram5<'_> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let d = self.0;
|
||||
let mut s = serializer.serialize_struct("Diagram", 8)?;
|
||||
s.serialize_field("id", &d.id)?;
|
||||
s.serialize_field("alias", &d.alias)?;
|
||||
s.serialize_field("name", &d.name)?;
|
||||
s.serialize_field("description", &d.description)?;
|
||||
s.serialize_field("layer", &d.layer)?;
|
||||
s.serialize_field("projection", &d.projection)?;
|
||||
s.serialize_field("nodes", &d.nodes)?;
|
||||
s.serialize_field("routes", &d.routes)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn source5_fingerprint_preserves_historical_field_order_and_diagram_shape() {
|
||||
// Literal follows the old Project/Diagram derives at 2b2d2bc. The FNV
|
||||
// value was independently calculated from these UTF-8 bytes with Python.
|
||||
let bytes = r#"{"schema_version":5,"id":"00000000-0000-0000-0000-000000000001","alias":"p","name":"Legacy","description":"","elements":{},"relations":{},"diagrams":{"00000000-0000-0000-0000-000000000002":{"id":"00000000-0000-0000-0000-000000000002","alias":"v","name":"View","description":"","layer":"Oa","projection":"Overview","nodes":{},"routes":[]}},"artifact_links":{},"traces":{}}"#;
|
||||
let mut p: Project = serde_json::from_str(bytes).unwrap();
|
||||
p.schema_version = 6;
|
||||
assert_eq!(serde_json::to_string(&Project5(&p)).unwrap(), bytes);
|
||||
assert_eq!(legacy_fingerprint(&p).unwrap(), "4140e158d5091161");
|
||||
assert_ne!(
|
||||
legacy_fingerprint(&p).unwrap(),
|
||||
oa_core::transitions::project_fingerprint(&p)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Source-5 checkpoint compatibility preserves authored bytes and disk contents.
|
||||
use super::*;
|
||||
|
||||
fn source5_snapshot() -> Snapshot {
|
||||
let accepted = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: [
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"// Preserve 5, 6 and Unicode 雨 in comments.\r\narcadia // header\r\n 5;\r\nproject p \"Recovered source 5\" id \"00000000-0000-4000-8000-000000000001\" {\r\n include \"work.arcadia\";\r\n}\r\n".into(),
|
||||
),
|
||||
(
|
||||
"work.arcadia".into(),
|
||||
"// Exact fragment, including unusual spacing.\r\noa_activity a \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}\r\n".into(),
|
||||
),
|
||||
].into(),
|
||||
};
|
||||
Snapshot {
|
||||
version: 1,
|
||||
id: Id::from_u128(100),
|
||||
name: "Recovered source 5".into(),
|
||||
origin: Some(PathBuf::from("original.arcadia")),
|
||||
modified: 1,
|
||||
workspace: Workspace::new(&accepted, true).recovery_snapshot(),
|
||||
accepted,
|
||||
property: None,
|
||||
behavior: None,
|
||||
workflow: None,
|
||||
selected: Id::from_u128(0x40008000000000000001),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_upgrades_only_header_and_clean_editor_baseline() {
|
||||
let saved = source5_snapshot();
|
||||
let mut expected = saved.accepted.clone();
|
||||
// Fixture-specific replacement is an independent oracle for the production token span.
|
||||
expected.files.insert(
|
||||
expected.entry.clone(),
|
||||
saved.accepted.files[&saved.accepted.entry].replacen(
|
||||
"arcadia // header\r\n 5;",
|
||||
"arcadia // header\r\n 6;",
|
||||
1,
|
||||
),
|
||||
);
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(restored.document.bundle(), &expected);
|
||||
let report = restored
|
||||
.document
|
||||
.compiled()
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (5, 6));
|
||||
assert_eq!(restored.workspace.text(), expected.files[&expected.entry]);
|
||||
assert!(!restored.workspace.dirty());
|
||||
assert!(restored.document.dirty());
|
||||
assert!(!restored.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_preserves_dirty_invalid_text_and_baseline_exactly() {
|
||||
let mut saved = source5_snapshot();
|
||||
let mut workspace = Workspace::new(&saved.accepted, true);
|
||||
let entry = workspace.entry.clone();
|
||||
let text = format!("{}\r\nunfinished {{ 雨", workspace.text());
|
||||
workspace.edit(text.clone(), workspace.files[&entry].cursor);
|
||||
workspace.new_path = "drafts/not-created.arcadia".into();
|
||||
saved.workspace = workspace.recovery_snapshot();
|
||||
let before = serde_json::to_value(&saved.workspace).unwrap();
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
before
|
||||
);
|
||||
assert_eq!(restored.workspace.text(), text);
|
||||
assert!(restored.workspace.dirty());
|
||||
assert!(restored.document.bundle().files[&entry].contains("arcadia // header\r\n 6;"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_preserves_distinct_unapplied_open_workspace() {
|
||||
let mut saved = source5_snapshot();
|
||||
let broken = SourceBundle {
|
||||
entry: "broken.arcadia".into(),
|
||||
files: [("broken.arcadia".into(), "unparsed {\r\n 雨".into())].into(),
|
||||
};
|
||||
saved.workspace = Workspace::new(&broken, false).recovery_snapshot();
|
||||
let before = serde_json::to_value(&saved.workspace).unwrap();
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(restored.workspace.dirty());
|
||||
assert_eq!(restored.document.project().name, "Recovered source 5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_upgrade_never_rewrites_checkpoint_or_origin() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut saved = source5_snapshot();
|
||||
let origin = directory.path().join("original.arcadia");
|
||||
fs::write(&origin, b"External origin bytes stay untouched.").unwrap();
|
||||
saved.origin = Some(origin.clone());
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
let restored = read(directory.path(), saved.id).unwrap().restore().unwrap();
|
||||
assert!(restored.document.dirty());
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
assert_eq!(
|
||||
fs::read(&origin).unwrap(),
|
||||
b"External origin bytes stay untouched."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_missing_identity_is_not_a_lossless_upgrade() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut saved = source5_snapshot();
|
||||
saved.accepted.files.insert(
|
||||
"work.arcadia".into(),
|
||||
"oa_activity a \"Work\" {}\r\n".into(),
|
||||
);
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
assert!(read(directory.path(), saved.id).unwrap().restore().is_err());
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source4_checkpoint_remains_explicitly_unsupported_without_touching_disk() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut saved = source5_snapshot();
|
||||
let entry = saved.accepted.entry.clone();
|
||||
saved.accepted.files.insert(
|
||||
entry.clone(),
|
||||
saved.accepted.files[&entry].replacen(
|
||||
"arcadia // header\r\n 5;",
|
||||
"arcadia // header\r\n 4;",
|
||||
1,
|
||||
),
|
||||
);
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
write(directory.path(), &saved).unwrap();
|
||||
let before = fs::read(path(directory.path(), saved.id)).unwrap();
|
||||
assert!(read(directory.path(), saved.id).unwrap().restore().is_err());
|
||||
assert_eq!(fs::read(path(directory.path(), saved.id)).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_workflow_rebinds_only_the_proven_old_revision() {
|
||||
use crate::workflow::Workflow;
|
||||
let mut saved = source5_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let old_base = source5::upgrade_binding(&saved.accepted, &document)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let work = *document.project().elements.keys().next().unwrap();
|
||||
let workflow = Workflow::transition(document.project(), work).unwrap();
|
||||
let mut raw = workflow.recovery_snapshot().unwrap();
|
||||
raw["Transition"]["base"] = old_base.into();
|
||||
// This field did not exist in source-5 request snapshots.
|
||||
raw["Transition"]["request"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("communication_endpoints");
|
||||
saved.workflow = Some(raw.clone());
|
||||
let restored = saved.clone().restore().unwrap();
|
||||
let Workflow::Transition(restored_workflow) = restored.workflow.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(restored_workflow.request.sources, vec![work]);
|
||||
assert_eq!(
|
||||
restored_workflow.plan.base_fingerprint,
|
||||
oa_core::transitions::project_fingerprint(restored.document.project())
|
||||
);
|
||||
assert!(!serde_json::to_string(&raw).unwrap().contains("commands"));
|
||||
raw["Transition"]["base"] = "different accepted revision".into();
|
||||
saved.workflow = Some(raw);
|
||||
assert!(saved.restore().err().unwrap().contains("revision"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_property_preserves_invalid_raw_input() {
|
||||
let mut saved = source5_snapshot();
|
||||
// Add a historical diagram and placement, with stable explicit identities.
|
||||
saved.accepted.files.get_mut("work.arcadia").unwrap().push_str(
|
||||
"diagram v \"View\" id \"00000000-0000-4000-8000-000000000003\" {}\r\nnode placed id \"00000000-0000-4000-8000-000000000004\" { diagram @v; element @a; }\r\n");
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let node = *document
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.keys()
|
||||
.next()
|
||||
.unwrap();
|
||||
let mut draft = Draft::from_project(document.project(), node).unwrap();
|
||||
draft.input("/position/x", "-".into());
|
||||
saved.property = Some(draft.recovery_snapshot());
|
||||
let restored = saved.restore().unwrap();
|
||||
let draft = restored.property.unwrap();
|
||||
assert_eq!(draft.inputs["/position/x"].raw, "-");
|
||||
assert!(draft.materialize().is_err());
|
||||
assert!(draft.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_port_draft_replans_and_rejects_stale_base() {
|
||||
use crate::{port_workflow, workflow::Workflow};
|
||||
let mut saved = source5_snapshot();
|
||||
saved
|
||||
.accepted
|
||||
.files
|
||||
.get_mut("work.arcadia")
|
||||
.unwrap()
|
||||
.push_str("sa_function f \"Function\" id \"00000000-0000-4000-8000-000000000005\" {}\r\n");
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let p = document.project();
|
||||
let f = p
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == oa_core::Kind::Function)
|
||||
.unwrap()
|
||||
.id;
|
||||
let old_base = source5::upgrade_binding(&saved.accepted, &document)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let state = port_workflow::State::new(p, f, port_workflow::Action::Input).unwrap();
|
||||
let mut raw = state.recovery_snapshot().unwrap();
|
||||
raw["name"] = " raw port name ".into();
|
||||
raw["base"] = old_base.into();
|
||||
saved.workflow = Some(serde_json::json!({"Ports": raw}));
|
||||
let restored = saved.clone().restore().unwrap();
|
||||
let Workflow::Ports(state) = restored.workflow.unwrap() else {
|
||||
panic!()
|
||||
};
|
||||
let restored_raw = state.recovery_snapshot().unwrap();
|
||||
assert_eq!(restored_raw["name"], " raw port name ");
|
||||
assert_eq!(
|
||||
restored_raw["base"],
|
||||
oa_core::transitions::project_fingerprint(restored.document.project())
|
||||
);
|
||||
saved.workflow.as_mut().unwrap()["Ports"]["base"] = "stale".into();
|
||||
assert!(saved.restore().err().unwrap().contains("revision"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_old_diagram_property_shape_remains_editable() {
|
||||
let mut saved = source5_snapshot();
|
||||
saved
|
||||
.accepted
|
||||
.files
|
||||
.get_mut("work.arcadia")
|
||||
.unwrap()
|
||||
.push_str("diagram v \"View\" id \"00000000-0000-4000-8000-000000000003\" {}\r\n");
|
||||
saved.workspace = Workspace::new(&saved.accepted, true).recovery_snapshot();
|
||||
let document = Document::from_bundle(saved.accepted.clone(), LIMITS).unwrap();
|
||||
let diagram = *document.project().diagrams.keys().next().unwrap();
|
||||
let mut draft = Draft::from_project(document.project(), diagram).unwrap();
|
||||
draft.input("/name", " raw view name ".into());
|
||||
let mut raw = serde_json::to_value(draft.recovery_snapshot()).unwrap();
|
||||
raw["value"].as_object_mut().unwrap().remove("view_options");
|
||||
raw["inputs"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|key, _| !key.starts_with("/view_options"));
|
||||
saved.property = Some(serde_json::from_value(raw).unwrap());
|
||||
let restored = saved.restore().unwrap();
|
||||
let draft = restored.property.unwrap();
|
||||
assert_eq!(draft.inputs["/name"].raw, " raw view name ");
|
||||
assert!(draft.materialize().is_ok());
|
||||
assert!(draft.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source5_checkpoint_does_not_refresh_unrelated_clean_buffer_baseline() {
|
||||
let mut saved = source5_snapshot();
|
||||
let mut raw = serde_json::to_value(&saved.workspace).unwrap();
|
||||
// A clean buffer retained from a different accepted source must not be
|
||||
// mistaken for the source-5 entry merely because its path matches.
|
||||
raw["files"]["project.arcadia"] = serde_json::json!(["different source", "different source"]);
|
||||
saved.workspace = serde_json::from_value(raw.clone()).unwrap();
|
||||
let restored = saved.restore().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.workspace.recovery_snapshot()).unwrap(),
|
||||
raw
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Deliberate semantic review of one existing trace; drafts never mutate the model.
|
||||
//! Atomic semantic review session with independent, stably addressed row drafts.
|
||||
use crate::{
|
||||
app::{Choice, Message},
|
||||
forms::Draft,
|
||||
@@ -8,6 +8,18 @@ use oa_core::{transitions::*, *};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
mod artifacts;
|
||||
#[cfg(test)]
|
||||
mod artifacts_recovery_falsifier_tests;
|
||||
#[cfg(test)]
|
||||
mod artifacts_tests;
|
||||
mod artifacts_view;
|
||||
mod attachments;
|
||||
mod attachments_view;
|
||||
mod batch;
|
||||
#[cfg(test)]
|
||||
mod batch_tests;
|
||||
mod compose;
|
||||
mod constructors;
|
||||
mod history;
|
||||
mod keyed;
|
||||
@@ -15,6 +27,7 @@ mod keyed_rows;
|
||||
mod message_kind;
|
||||
mod ordered;
|
||||
mod recovery;
|
||||
mod row;
|
||||
mod scalar;
|
||||
mod view;
|
||||
pub(crate) use view::summary;
|
||||
@@ -36,13 +49,17 @@ pub enum Tab {
|
||||
Facts,
|
||||
References,
|
||||
Members,
|
||||
Attachments,
|
||||
Artifacts,
|
||||
History,
|
||||
Impacts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
pub enum RowEvent {
|
||||
#[cfg(test)]
|
||||
Trace(Choice),
|
||||
#[cfg(test)]
|
||||
Direction(TransitionDirection),
|
||||
Select(FacetSelector),
|
||||
Decide(FacetSelector, Option<SemanticDecision>),
|
||||
@@ -53,12 +70,15 @@ pub enum Event {
|
||||
Array(String, Option<usize>, i32),
|
||||
Append(String, Value),
|
||||
ValuePath(String, crate::data_values::FieldPathKey),
|
||||
ValueBrowse(String, Option<crate::data_values::FieldPathKey>, usize),
|
||||
ReferenceBrowse(String, String, usize),
|
||||
Tab(Tab),
|
||||
ShowUnchanged(bool),
|
||||
ReferenceOa(Choice),
|
||||
ReferenceSa(Choice),
|
||||
ReferenceSearch(String),
|
||||
AddReference,
|
||||
RestoreReviewedReferences,
|
||||
RemoveReference(Id),
|
||||
AcceptImpact(usize, bool),
|
||||
Preview,
|
||||
@@ -71,6 +91,8 @@ pub enum Event {
|
||||
LiteralSide(Layer, bool),
|
||||
EditPair(FacetSelector),
|
||||
StageMember,
|
||||
Attachment(attachments::Event),
|
||||
Artifact(artifacts::Event),
|
||||
DropStaged(FacetSelector),
|
||||
AnnotationAddress(Layer, Option<crate::data_values::FieldPathKey>),
|
||||
AnnotationBrowse(Layer, Option<crate::data_values::FieldPathKey>, usize),
|
||||
@@ -82,824 +104,5 @@ pub enum Event {
|
||||
OrderReconcile(OrderedCollection),
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
base_identity: Id,
|
||||
base_fingerprint: String,
|
||||
pub anchor: Id,
|
||||
pub trace: Option<Id>,
|
||||
pub direction: TransitionDirection,
|
||||
pub facts: Vec<FacetDrift>,
|
||||
pub selected: Option<FacetSelector>,
|
||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
pub resets: BTreeSet<FacetSelector>,
|
||||
pub staged: BTreeSet<FacetSelector>,
|
||||
pub presence: BTreeMap<FacetSelector, bool>,
|
||||
pub row_drafts: BTreeMap<FacetSelector, keyed_rows::RowDraft>,
|
||||
pub order_drafts: BTreeMap<OrderedCollection, Vec<Id>>,
|
||||
pub annotation_oa: keyed_rows::AddressDraft,
|
||||
pub annotation_sa: keyed_rows::AddressDraft,
|
||||
pub member_family: keyed::Family,
|
||||
pub member_oa: Option<Choice>,
|
||||
pub member_sa: Option<Choice>,
|
||||
pub literal: String,
|
||||
pub literal_oa: bool,
|
||||
pub literal_sa: bool,
|
||||
pub draft: Option<Draft>,
|
||||
// Each closed constructor has an independent draft, including nested kinds.
|
||||
// Scalar and sibling drafts survive decision and constructor changes.
|
||||
pub group_drafts: BTreeMap<FacetSelector, Draft>,
|
||||
pub plan: Option<SemanticTransitionPlan>,
|
||||
pub proposed: Option<Project>,
|
||||
pub error: Option<String>,
|
||||
pub tab: Tab,
|
||||
pub show_unchanged: bool,
|
||||
pub reference_oa: Option<Choice>,
|
||||
pub reference_sa: Option<Choice>,
|
||||
pub reference_search: String,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
||||
let explicit = project.traces.get(&selected);
|
||||
let anchor = explicit.map_or(selected, |trace| trace.oa);
|
||||
let layer = project
|
||||
.layer_of(anchor)
|
||||
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
|
||||
let choices: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == anchor || t.sa == anchor)
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
|
||||
}
|
||||
let trace = explicit
|
||||
.map(|t| t.id)
|
||||
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
|
||||
let mut state = Self {
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
anchor,
|
||||
trace,
|
||||
direction: if layer == Layer::Oa {
|
||||
TransitionDirection::OaToSa
|
||||
} else {
|
||||
TransitionDirection::SaToOa
|
||||
},
|
||||
facts: vec![],
|
||||
selected: None,
|
||||
decisions: BTreeMap::new(),
|
||||
references: BTreeMap::new(),
|
||||
resets: BTreeSet::new(),
|
||||
staged: BTreeSet::new(),
|
||||
presence: BTreeMap::new(),
|
||||
row_drafts: BTreeMap::new(),
|
||||
order_drafts: BTreeMap::new(),
|
||||
annotation_oa: Default::default(),
|
||||
annotation_sa: Default::default(),
|
||||
member_family: keyed::Family::Literal,
|
||||
member_oa: None,
|
||||
member_sa: None,
|
||||
literal: String::new(),
|
||||
literal_oa: true,
|
||||
literal_sa: true,
|
||||
draft: None,
|
||||
group_drafts: BTreeMap::new(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
tab: Tab::Facts,
|
||||
show_unchanged: false,
|
||||
reference_oa: None,
|
||||
reference_sa: None,
|
||||
reference_search: String::new(),
|
||||
accepted_impacts: vec![],
|
||||
};
|
||||
state.load_trace(project);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn target(&self, project: &Project) -> Option<Id> {
|
||||
let trace = project.traces.get(&self.trace?)?;
|
||||
Some(if self.direction == TransitionDirection::OaToSa {
|
||||
trace.sa
|
||||
} else {
|
||||
trace.oa
|
||||
})
|
||||
}
|
||||
|
||||
fn load_trace(&mut self, project: &Project) {
|
||||
self.facts.clear();
|
||||
self.decisions.clear();
|
||||
self.resets.clear();
|
||||
self.staged.clear();
|
||||
self.presence.clear();
|
||||
self.row_drafts.clear();
|
||||
self.order_drafts.clear();
|
||||
self.annotation_oa = Default::default();
|
||||
self.annotation_sa = Default::default();
|
||||
self.member_oa = None;
|
||||
self.member_sa = None;
|
||||
self.literal.clear();
|
||||
self.literal_oa = true;
|
||||
self.literal_sa = true;
|
||||
self.selected = None;
|
||||
self.accepted_impacts.clear();
|
||||
self.references.clear();
|
||||
self.draft = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id));
|
||||
self.group_drafts.clear();
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
for facet in constructors::selectors() {
|
||||
if constructors::available(project, trace, &facet)
|
||||
&& let Some(mut draft) = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id))
|
||||
{
|
||||
draft.semantic_groups = true;
|
||||
self.group_drafts.insert(facet, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
self.references.insert(trace.oa, trace.sa);
|
||||
}
|
||||
self.member_family = self
|
||||
.member_families(project)
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(keyed::Family::Literal);
|
||||
self.refresh(project);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, project: &Project, event: Event) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
Event::Trace(choice) => {
|
||||
if !project
|
||||
.traces
|
||||
.get(&choice.id)
|
||||
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
|
||||
{
|
||||
Err("Choose one of this object's existing traces.".into())
|
||||
} else {
|
||||
self.trace = Some(choice.id);
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Event::Direction(direction) => {
|
||||
self.direction = direction;
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
Event::Select(facet) => {
|
||||
self.selected = Some(facet);
|
||||
self.tab = Tab::Facts;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::Decide(facet, decision) => {
|
||||
if let Some(decision) = decision {
|
||||
if !self.editable(project, &facet)
|
||||
&& !(decision == SemanticDecision::KeepTarget
|
||||
&& self.can_acknowledge_constructor(project, &facet))
|
||||
{
|
||||
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
||||
} else {
|
||||
if let FacetSelector::Order(which) = facet
|
||||
&& decision == SemanticDecision::Candidate
|
||||
{
|
||||
if let Err(error) = self.initialize_order(project, which) {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
} else if keyed_rows::supported(&facet) {
|
||||
self.initialize_row(project, &facet);
|
||||
} else if keyed::supported(&facet) {
|
||||
let target_presence = self
|
||||
.effective_trace(project)
|
||||
.and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok())
|
||||
.and_then(|f| scalar::value(&f, self.direction.target_layer()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
self.presence
|
||||
.entry(facet.clone())
|
||||
.or_insert(target_presence);
|
||||
}
|
||||
self.decisions.insert(facet, decision);
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
self.decisions.remove(&facet);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Event::Input(path, value) => self.edit(project, &path, |d| {
|
||||
d.input(&path, value);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Text(path, action) => self.edit(project, &path, |d| {
|
||||
d.edit_text(&path, action);
|
||||
Ok(())
|
||||
}),
|
||||
Event::Shape(path, value) => {
|
||||
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||
}
|
||||
Event::Variant(path, value) => {
|
||||
self.edit(project, &path, |d| d.variant_scoped(&path, value))
|
||||
}
|
||||
Event::Array(path, index, direction) => {
|
||||
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
||||
}
|
||||
Event::Append(path, value) => {
|
||||
self.edit(project, &path, |d| d.append_scoped(&path, value))
|
||||
}
|
||||
Event::ValuePath(path, key) => {
|
||||
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
|
||||
}
|
||||
Event::Tab(tab) => {
|
||||
self.tab = tab;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ShowUnchanged(show) => {
|
||||
self.show_unchanged = show;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceOa(choice) => {
|
||||
self.reference_oa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceSa(choice) => {
|
||||
self.reference_sa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::ReferenceSearch(value) => {
|
||||
self.reference_search = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::AddReference => match (&self.reference_oa, &self.reference_sa) {
|
||||
(Some(oa), Some(sa)) => {
|
||||
self.references.insert(oa.id, sa.id);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("Choose both an Operational and a System reference.".into()),
|
||||
},
|
||||
Event::RemoveReference(id) => {
|
||||
self.references.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
Event::AcceptImpact(index, accepted) => {
|
||||
if let Some(impact) = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.and_then(|p| p.impacts.get(index))
|
||||
.cloned()
|
||||
{
|
||||
self.accepted_impacts.retain(|i| i.object != impact.object);
|
||||
if accepted {
|
||||
self.accepted_impacts.push(impact);
|
||||
}
|
||||
}
|
||||
self.refresh(project);
|
||||
return;
|
||||
}
|
||||
Event::Preview => Ok(()),
|
||||
Event::OrderMove(which, index, delta) => self.move_order(which, index, delta),
|
||||
Event::OrderReconcile(which) => self.reconcile_order(project, which),
|
||||
Event::Reset(facet, reset) => self.reset_history(project, facet, reset),
|
||||
Event::Presence(facet, present) => {
|
||||
if keyed::supported(&facet)
|
||||
&& self.decisions.get(&facet) == Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
self.presence.insert(facet, present);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Choose Set final value for this member before editing presence.".into())
|
||||
}
|
||||
}
|
||||
Event::MemberFamily(family) => {
|
||||
self.member_family = family;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::MemberOa(choice) => {
|
||||
self.member_oa = choice;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::MemberSa(choice) => {
|
||||
self.member_sa = choice;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::Literal(value) => {
|
||||
self.literal = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::LiteralSide(layer, present) => {
|
||||
if layer == Layer::Oa {
|
||||
self.literal_oa = present;
|
||||
} else {
|
||||
self.literal_sa = present;
|
||||
}
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::EditPair(facet) => {
|
||||
replan = false;
|
||||
self.edit_pair(project, facet)
|
||||
}
|
||||
Event::StageMember => self.stage_member(project),
|
||||
Event::DropStaged(facet) => {
|
||||
if self.staged.remove(&facet) {
|
||||
self.decisions.remove(&facet);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Event::AnnotationAddress(layer, key) => {
|
||||
self.annotation_address_mut(layer).selected = key;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::AnnotationBrowse(layer, scope, page) => {
|
||||
let address = self.annotation_address_mut(layer);
|
||||
address.scope = scope;
|
||||
address.page = page;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
Event::RowExpression(facet, expression) => self.edit_row(&facet, |draft| {
|
||||
draft.editor = text_editor::Content::with_text(&expression);
|
||||
draft.expression = expression;
|
||||
}),
|
||||
Event::RowText(facet, action) => self.edit_row(&facet, |draft| {
|
||||
draft.editor.perform(action);
|
||||
draft.expression = draft.editor.text();
|
||||
}),
|
||||
Event::RowLocal(facet, local) => self.edit_row(&facet, |draft| {
|
||||
draft.local = Some(local);
|
||||
}),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.error = Some(error);
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
return;
|
||||
}
|
||||
if replan {
|
||||
self.accepted_impacts.clear();
|
||||
self.refresh(project);
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
action: impl FnOnce(&mut Draft) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
|
||||
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
||||
return Err("Choose Set final value before editing this property.".into());
|
||||
}
|
||||
let roots = self
|
||||
.target(project)
|
||||
.and_then(|id| constructors::roots(project, id, facet))
|
||||
.ok_or("Selected property is unavailable.")?;
|
||||
if !roots
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
{
|
||||
return Err("Only the selected review property can change in this draft.".into());
|
||||
}
|
||||
let draft = if scalar::grouped(facet) {
|
||||
self.group_drafts.get_mut(facet)
|
||||
} else {
|
||||
self.draft.as_mut()
|
||||
}
|
||||
.ok_or("Choose an existing target.")?;
|
||||
// The scoped view sends only the selected property or closed group roots.
|
||||
// Protected identity and top-level body/perspective controls are never exposed.
|
||||
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
||||
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
||||
}
|
||||
action(draft)
|
||||
}
|
||||
|
||||
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||
if keyed::supported(facet) || matches!(facet, FacetSelector::Order(_)) {
|
||||
return self
|
||||
.effective_trace(project)
|
||||
.is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok());
|
||||
}
|
||||
self.target(project)
|
||||
.and_then(|id| scalar::binding(project, id, facet))
|
||||
.is_some()
|
||||
&& (!scalar::grouped(facet)
|
||||
|| (self.group_drafts.contains_key(facet)
|
||||
&& self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.is_some_and(|trace| constructors::available(project, trace, facet))))
|
||||
}
|
||||
|
||||
fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> {
|
||||
if scalar::grouped(facet) {
|
||||
self.group_drafts.get(facet)
|
||||
} else {
|
||||
self.draft.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||
if !scalar::grouped(facet) {
|
||||
return false;
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return false;
|
||||
};
|
||||
if trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
capture_facet(project, trace.oa, trace.sa, facet)
|
||||
.ok()
|
||||
.is_some_and(|current| {
|
||||
scalar::value(¤t, Layer::Oa).is_some()
|
||||
&& scalar::observation(¤t, Layer::Oa)
|
||||
== scalar::observation(¤t, Layer::Sa)
|
||||
})
|
||||
}
|
||||
|
||||
fn request(&self, project: &Project) -> Result<SemanticTransitionRequest, String> {
|
||||
let trace = self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.ok_or("Choose the trace whose review history you want to update.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut transition = TransitionRequest::new(self.direction, vec![source]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.traces.insert(source, trace.id);
|
||||
request.references = self.references.clone();
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source,
|
||||
resolutions: self
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: facet.clone(),
|
||||
decision: *decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: self.resets.iter().cloned().collect(),
|
||||
});
|
||||
request.accepted_impacts = self.accepted_impacts.clone();
|
||||
if self
|
||||
.decisions
|
||||
.values()
|
||||
.any(|d| *d == SemanticDecision::Candidate)
|
||||
{
|
||||
let final_value = self.combined_value(project, false)?;
|
||||
let candidate = if project.elements.contains_key(&target) {
|
||||
SemanticRecord::Element(
|
||||
serde_json::from_value(final_value)
|
||||
.map_err(|e| format!("Final target value: {e}"))?,
|
||||
)
|
||||
} else {
|
||||
SemanticRecord::Relation(
|
||||
serde_json::from_value(final_value)
|
||||
.map_err(|e| format!("Final target value: {e}"))?,
|
||||
)
|
||||
};
|
||||
request.candidates.push(candidate);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Compose selected values for both the candidate and dependent form
|
||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||
/// request construction always requires every selected input to parse.
|
||||
fn combined_value(&self, project: &Project, form_context: bool) -> Result<Value, String> {
|
||||
self.combine(project, form_context, false)
|
||||
}
|
||||
|
||||
fn combine(
|
||||
&self,
|
||||
project: &Project,
|
||||
form_context: bool,
|
||||
skip_orders: bool,
|
||||
) -> Result<Value, String> {
|
||||
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut final_value = Draft::from_project(project, target)
|
||||
.ok_or("Target has no editable record.")?
|
||||
.value;
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
map.insert(source, target);
|
||||
let original = final_value.clone();
|
||||
let mut group_candidates = vec![];
|
||||
// Outer groups precede nested groups and leaves. The core rejects actual
|
||||
// overlapping replacement scopes; this order also supports first-equal
|
||||
// discriminator acknowledgment together with explicit descendant values.
|
||||
let mut decisions: Vec<_> = self.decisions.iter().collect();
|
||||
decisions.sort_by_key(|(facet, _)| {
|
||||
let group = match facet {
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
|
||||
facet if scalar::grouped(facet) => 1,
|
||||
FacetSelector::Order(_) => 3,
|
||||
_ => 2,
|
||||
};
|
||||
(
|
||||
group,
|
||||
keyed_rows::target_selector(facet, self.direction.target_layer()),
|
||||
)
|
||||
});
|
||||
for (facet, decision) in decisions {
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
if let FacetSelector::Order(which) = facet {
|
||||
if !skip_orders {
|
||||
// Invalid order drafts remain visible while dependent controls use the
|
||||
// surviving membership; request construction still rejects them.
|
||||
if let Err(error) =
|
||||
self.project_order(project, &trace, &mut final_value, *which, *decision)
|
||||
&& !form_context
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(facet, FacetSelector::Member { collection, .. } if ordered::ordered(*collection).is_some())
|
||||
{
|
||||
self.project_ordered_member(
|
||||
project,
|
||||
&trace,
|
||||
&mut final_value,
|
||||
facet,
|
||||
*decision,
|
||||
form_context,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if keyed::supported(facet) {
|
||||
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
|
||||
continue;
|
||||
}
|
||||
let roots = constructors::roots(project, target, facet)
|
||||
.ok_or("Selected property is not applicable to the target.")?;
|
||||
let supplied = match decision {
|
||||
SemanticDecision::KeepTarget => continue,
|
||||
SemanticDecision::Candidate => {
|
||||
let draft = self
|
||||
.candidate_draft(facet)
|
||||
.ok_or("Target draft is unavailable.")?;
|
||||
match draft.materialize_scopes(&roots) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource if scalar::grouped(facet) => {
|
||||
if self.can_acknowledge_constructor(project, facet) {
|
||||
original.clone()
|
||||
} else {
|
||||
constructors::take_value(project, &trace, self.direction, facet, &map)?
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
|
||||
let mut supplied = original.clone();
|
||||
*supplied
|
||||
.pointer_mut(&roots[0])
|
||||
.ok_or("Source property is unavailable.")? = value;
|
||||
supplied
|
||||
}
|
||||
};
|
||||
for path in roots {
|
||||
let value = supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Candidate property is no longer available.")?;
|
||||
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
|
||||
group_candidates.push((path.clone(), value.clone()));
|
||||
}
|
||||
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
|
||||
}
|
||||
}
|
||||
if !form_context {
|
||||
for (path, authored) in group_candidates {
|
||||
if !original
|
||||
.pointer(&path)
|
||||
.zip(final_value.pointer(&path))
|
||||
.is_some_and(|(original, final_value)| {
|
||||
constructors::authored_agrees(original, &authored, final_value)
|
||||
})
|
||||
{
|
||||
return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(final_value)
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
let context = self.combined_value(project, true).ok();
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
for draft in self.group_drafts.values_mut() {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
self.error = Some(
|
||||
"Several traces are available. Choose the correspondence and review history above."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
match semantic_facets(project, &trace, &self.references) {
|
||||
Ok(mut facts) => {
|
||||
// Explicit addresses survive catalog normalization, pairing changes and resets.
|
||||
for selector in self.staged.iter().chain(self.decisions.keys()) {
|
||||
if !facts.iter().any(|f| &f.selector == selector) {
|
||||
match capture_facet(project, trace.oa, trace.sa, selector) {
|
||||
Ok(current) => {
|
||||
let previous = trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.and_then(|b| {
|
||||
b.facets.iter().find(|f| f.selector() == *selector)
|
||||
});
|
||||
facts.push(FacetDrift {
|
||||
selector: selector.clone(),
|
||||
drift: facet_drift(¤t, previous),
|
||||
current,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
facts.sort_by(|a, b| a.selector.cmp(&b.selector));
|
||||
self.facts = facts;
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.selected
|
||||
.as_ref()
|
||||
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
|
||||
{
|
||||
self.selected = self
|
||||
.facts
|
||||
.iter()
|
||||
.find(|f| f.drift != Drift::Unchanged)
|
||||
.or(self.facts.first())
|
||||
.map(|f| f.selector.clone());
|
||||
}
|
||||
let request = match self.request(project) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match plan_semantic_transition(project, request) {
|
||||
Ok(plan) => {
|
||||
if plan.conflicts.is_empty() {
|
||||
match prepare_semantic_transition(project, &plan) {
|
||||
Ok(prepared) => self.proposed = Some(prepared.project),
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
self.plan = Some(plan);
|
||||
}
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||
if self.decisions.is_empty() && self.resets.is_empty() {
|
||||
return Err(
|
||||
"Choose at least one property to review or an exact history observation to reset."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let plan = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.ok_or("Refresh the review preview before applying.")?;
|
||||
prepare_semantic_transition(project, plan).map_err(|e| e.to_string())?;
|
||||
Ok(Command::Batch(plan.commands.clone()))
|
||||
}
|
||||
|
||||
pub fn can_apply(&self) -> bool {
|
||||
(!self.decisions.is_empty() || !self.resets.is_empty())
|
||||
&& self.error.is_none()
|
||||
&& self
|
||||
.plan
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.conflicts.is_empty() && p.prepared_fingerprint.is_some())
|
||||
}
|
||||
|
||||
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||
let historical: BTreeSet<_> = self
|
||||
.facts
|
||||
.iter()
|
||||
.flat_map(|f| scalar::references(&f.current))
|
||||
.chain(
|
||||
self.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
|
||||
)
|
||||
.collect();
|
||||
let mut ids: BTreeSet<_> = project
|
||||
.elements
|
||||
.keys()
|
||||
.chain(project.relations.keys())
|
||||
.chain(project.artifact_links.keys())
|
||||
.filter(|id| project.layer_of(**id) == Some(layer))
|
||||
.copied()
|
||||
.collect();
|
||||
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
|
||||
let search = self.reference_search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| view::choice(project, id))
|
||||
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
|
||||
.collect();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
}
|
||||
|
||||
pub fn form_event(message: Message) -> Message {
|
||||
let event = match message {
|
||||
Message::Field(path, value) => Event::Input(path, value),
|
||||
Message::FieldText(path, action) => Event::Text(path, action),
|
||||
Message::FieldShape(path, value) => Event::Shape(path, value),
|
||||
Message::FieldVariant(path, value) => Event::Variant(path, value),
|
||||
Message::FieldArray(path, index, direction) => Event::Array(path, index, direction),
|
||||
Message::FieldAppend(path, value) => Event::Append(path, value),
|
||||
Message::FieldValuePath(path, key) => Event::ValuePath(path, key),
|
||||
_ => Event::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event))
|
||||
}
|
||||
pub use batch::{Event, RowAddress, State};
|
||||
pub use row::ReviewRow;
|
||||
|
||||
@@ -0,0 +1,615 @@
|
||||
//! Typed artifact lifecycle drafts; all authored values belong to an exact pair/facet.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
pub(super) const METADATA: [ArtifactFacet; 7] = [
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Description,
|
||||
ArtifactFacet::Owner,
|
||||
ArtifactFacet::Relation,
|
||||
ArtifactFacet::Uri,
|
||||
ArtifactFacet::Revision,
|
||||
ArtifactFacet::Fragment,
|
||||
];
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum Raw {
|
||||
Text(String),
|
||||
Owner(String),
|
||||
Relation(ArtifactRelation),
|
||||
Optional { present: bool, text: String },
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Pick(Layer, Option<Choice>),
|
||||
Search(String),
|
||||
Stage,
|
||||
Reserve,
|
||||
Input(FacetSelector, Raw),
|
||||
Description(FacetSelector, text_editor::Action),
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Editor {
|
||||
pub oa: Option<Choice>,
|
||||
pub sa: Option<Choice>,
|
||||
pub search: String,
|
||||
pub seeds: BTreeMap<Id, ArtifactLink>,
|
||||
pub drafts: BTreeMap<FacetSelector, Raw>,
|
||||
pub descriptions: BTreeMap<FacetSelector, text_editor::Content>,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Saved {
|
||||
oa: Option<Id>,
|
||||
sa: Option<Id>,
|
||||
seeds: Vec<ArtifactLink>,
|
||||
drafts: Vec<(FacetSelector, Raw)>,
|
||||
}
|
||||
impl Saved {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.oa.is_none() && self.sa.is_none() && self.seeds.is_empty() && self.drafts.is_empty()
|
||||
}
|
||||
}
|
||||
pub struct Proposal {
|
||||
// The final selector addresses the actual mutation slot; the last selector
|
||||
// retains the reviewed facet solely for conflict diagnostics.
|
||||
pub slots: Vec<((Id, FacetSelector), Value, FacetSelector)>,
|
||||
pub creations: Vec<ArtifactLink>,
|
||||
pub candidates: BTreeSet<Id>,
|
||||
}
|
||||
pub(super) fn parts(facet: &FacetSelector) -> Option<(&KeyPair<Id>, ArtifactFacet)> {
|
||||
if let FacetSelector::Artifact { keys, facet } = facet {
|
||||
Some((keys, *facet))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
parts(facet).is_some()
|
||||
}
|
||||
pub(super) fn selector(keys: &KeyPair<Id>, facet: ArtifactFacet) -> FacetSelector {
|
||||
FacetSelector::Artifact {
|
||||
keys: keys.clone(),
|
||||
facet,
|
||||
}
|
||||
}
|
||||
pub(super) fn key(keys: &KeyPair<Id>, layer: Layer) -> Option<Id> {
|
||||
if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
}
|
||||
pub(super) fn field(facet: ArtifactFacet) -> Option<&'static str> {
|
||||
Some(match facet {
|
||||
ArtifactFacet::Presence => return None,
|
||||
ArtifactFacet::Name => "name",
|
||||
ArtifactFacet::Description => "description",
|
||||
ArtifactFacet::Owner => "owner",
|
||||
ArtifactFacet::Relation => "relation",
|
||||
ArtifactFacet::Uri => "uri",
|
||||
ArtifactFacet::Revision => "revision",
|
||||
ArtifactFacet::Fragment => "fragment",
|
||||
})
|
||||
}
|
||||
pub(super) fn slot(id: Id, facet: ArtifactFacet) -> (Id, FacetSelector) {
|
||||
(
|
||||
id,
|
||||
selector(
|
||||
&KeyPair {
|
||||
oa: None,
|
||||
sa: Some(id),
|
||||
},
|
||||
if facet == ArtifactFacet::Presence {
|
||||
ArtifactFacet::Owner
|
||||
} else {
|
||||
facet
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
pub(super) fn observation(record: Option<&ArtifactLink>, facet: ArtifactFacet) -> Value {
|
||||
match record {
|
||||
None => Value::String("NotApplicable".into()),
|
||||
Some(record) => {
|
||||
serde_json::json!({"Value":serde_json::to_value(record).expect("Artifact")[field(facet).unwrap_or("owner")]})
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn raw(record: &ArtifactLink, facet: ArtifactFacet) -> Option<Raw> {
|
||||
Some(match facet {
|
||||
ArtifactFacet::Presence => return None,
|
||||
ArtifactFacet::Name => Raw::Text(record.name.clone()),
|
||||
ArtifactFacet::Description => Raw::Text(record.description.clone()),
|
||||
ArtifactFacet::Uri => Raw::Text(record.uri.clone()),
|
||||
ArtifactFacet::Owner => Raw::Owner(record.owner.to_string()),
|
||||
ArtifactFacet::Relation => Raw::Relation(record.relation),
|
||||
ArtifactFacet::Revision => Raw::Optional {
|
||||
present: record.revision.is_some(),
|
||||
text: record.revision.clone().unwrap_or_default(),
|
||||
},
|
||||
ArtifactFacet::Fragment => Raw::Optional {
|
||||
present: record.fragment.is_some(),
|
||||
text: record.fragment.clone().unwrap_or_default(),
|
||||
},
|
||||
})
|
||||
}
|
||||
fn compatible(facet: ArtifactFacet, raw: &Raw) -> bool {
|
||||
matches!(
|
||||
(facet, raw),
|
||||
(
|
||||
ArtifactFacet::Name | ArtifactFacet::Description | ArtifactFacet::Uri,
|
||||
Raw::Text(_)
|
||||
) | (ArtifactFacet::Owner, Raw::Owner(_))
|
||||
| (ArtifactFacet::Relation, Raw::Relation(_))
|
||||
| (
|
||||
ArtifactFacet::Revision | ArtifactFacet::Fragment,
|
||||
Raw::Optional { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
fn write(record: &mut ArtifactLink, facet: ArtifactFacet, raw: &Raw) -> Result<(), String> {
|
||||
match (facet, raw) {
|
||||
(ArtifactFacet::Name, Raw::Text(v)) => record.name = v.clone(),
|
||||
(ArtifactFacet::Description, Raw::Text(v)) => record.description = v.clone(),
|
||||
(ArtifactFacet::Uri, Raw::Text(v)) => record.uri = v.clone(),
|
||||
(ArtifactFacet::Owner, Raw::Owner(v)) => {
|
||||
record.owner = v
|
||||
.parse()
|
||||
.map_err(|_| "Choose a valid explicit artifact owner identity.")?
|
||||
}
|
||||
(ArtifactFacet::Relation, Raw::Relation(v)) => record.relation = *v,
|
||||
(ArtifactFacet::Revision, Raw::Optional { present, text }) => {
|
||||
record.revision = present.then(|| text.clone())
|
||||
}
|
||||
(ArtifactFacet::Fragment, Raw::Optional { present, text }) => {
|
||||
record.fragment = present.then(|| text.clone())
|
||||
}
|
||||
_ => return Err("Artifact draft type disagrees with its selected metadata facet.".into()),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
impl Editor {
|
||||
pub fn pair(&self) -> KeyPair<Id> {
|
||||
KeyPair {
|
||||
oa: self.oa.as_ref().map(|c| c.id),
|
||||
sa: self.sa.as_ref().map(|c| c.id),
|
||||
}
|
||||
}
|
||||
pub fn select_pair(&mut self, p: &Project, facet: &FacetSelector) -> Result<(), String> {
|
||||
let (keys, _) = parts(facet).ok_or("Choose an artifact address.")?;
|
||||
self.oa = keys.oa.map(|id| self.choice(p, id));
|
||||
self.sa = keys.sa.map(|id| self.choice(p, id));
|
||||
Ok(())
|
||||
}
|
||||
pub fn choice(&self, p: &Project, id: Id) -> Choice {
|
||||
if p.contains(id) {
|
||||
return view::choice(p, id);
|
||||
}
|
||||
self.seeds.get(&id).map_or_else(
|
||||
|| view::choice(p, id),
|
||||
|a| Choice {
|
||||
id,
|
||||
label: format!("{} · reserved @{}", a.name, a.alias),
|
||||
},
|
||||
)
|
||||
}
|
||||
pub fn choices(&self, p: &Project, trace: &Trace, layer: Layer) -> Vec<Choice> {
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let selected = key(&self.pair(), layer);
|
||||
let mut ids: BTreeSet<_> = p
|
||||
.artifact_links
|
||||
.values()
|
||||
.filter(|a| p.layer_of(a.owner).is_none_or(|actual| actual == layer))
|
||||
.map(|a| a.id)
|
||||
.chain(
|
||||
self.seeds
|
||||
.values()
|
||||
.filter(|a| a.owner == owner)
|
||||
.map(|a| a.id),
|
||||
)
|
||||
.collect();
|
||||
if let Some(id) = selected {
|
||||
ids.insert(id);
|
||||
}
|
||||
if let Some(base) = trace.baseline.as_ref().and_then(|b| b.semantic.as_ref()) {
|
||||
for fact in &base.facets {
|
||||
if let Some((keys, _)) = parts(&fact.selector())
|
||||
&& let Some(id) = key(keys, layer)
|
||||
{
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let search = self.search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let mut choice = self.choice(p, id);
|
||||
if let Some(artifact) = p.artifact_links.get(&id) {
|
||||
choice.label.push_str(&format!(
|
||||
" · owner: {}{}",
|
||||
p.named(artifact.owner),
|
||||
if artifact.owner == owner {
|
||||
""
|
||||
} else {
|
||||
" · not attached to reviewed owner"
|
||||
}
|
||||
));
|
||||
}
|
||||
choice
|
||||
})
|
||||
.filter(|c| Some(c.id) == selected || c.label.to_lowercase().contains(&search))
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Result<(), String> {
|
||||
let (keys, which) = parts(facet).ok_or("Choose artifact metadata.")?;
|
||||
let id = key(keys, layer).ok_or("Choose a destination artifact identity.")?;
|
||||
let record = p
|
||||
.artifact_links
|
||||
.get(&id)
|
||||
.or_else(|| self.seeds.get(&id))
|
||||
.ok_or("Reserve a destination counterpart before reviewing its metadata.")?;
|
||||
let value = raw(record, which).ok_or(
|
||||
"Presence uses explicit Keep or Take; there is no deletion tombstone candidate.",
|
||||
)?;
|
||||
self.drafts.entry(facet.clone()).or_insert(value);
|
||||
if which == ArtifactFacet::Description
|
||||
&& let Some(Raw::Text(text)) = self.drafts.get(facet)
|
||||
{
|
||||
self.descriptions
|
||||
.entry(facet.clone())
|
||||
.or_insert_with(|| text_editor::Content::with_text(text));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
event: Event,
|
||||
) -> Result<Option<Vec<FacetSelector>>, String> {
|
||||
match event {
|
||||
Event::Pick(layer, choice) => {
|
||||
if layer == Layer::Oa {
|
||||
self.oa = choice
|
||||
} else {
|
||||
self.sa = choice
|
||||
}
|
||||
}
|
||||
Event::Search(search) => self.search = search,
|
||||
Event::Stage => {
|
||||
let keys = self.pair();
|
||||
if keys.oa.is_none() && keys.sa.is_none() {
|
||||
return Err("Choose at least one exact artifact identity.".into());
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
if key(&keys, layer)
|
||||
.and_then(|id| p.layer_of(id))
|
||||
.is_some_and(|actual| actual != layer)
|
||||
{
|
||||
return Err(
|
||||
"Artifact pair identity belongs to the other perspective.".into()
|
||||
);
|
||||
}
|
||||
}
|
||||
capture_facet(
|
||||
p,
|
||||
trace.oa,
|
||||
trace.sa,
|
||||
&selector(&keys, ArtifactFacet::Presence),
|
||||
)?;
|
||||
return Ok(Some(
|
||||
std::iter::once(ArtifactFacet::Presence)
|
||||
.chain(METADATA)
|
||||
.map(|facet| selector(&keys, facet))
|
||||
.filter(|facet| capture_facet(p, trace.oa, trace.sa, facet).is_ok())
|
||||
.collect(),
|
||||
));
|
||||
}
|
||||
Event::Reserve => {
|
||||
let source = key(&self.pair(), direction.source_layer())
|
||||
.and_then(|id| p.artifact_links.get(&id))
|
||||
.ok_or(
|
||||
"Choose an existing source artifact before reserving its counterpart.",
|
||||
)?;
|
||||
let source_owner = if direction.source_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
if source.owner != source_owner {
|
||||
return Err(
|
||||
"The source artifact is not attached to this reviewed owner.".into(),
|
||||
);
|
||||
}
|
||||
let mut seed = source.clone();
|
||||
seed.id = Id::new_v4();
|
||||
seed.alias = format!("artifact_{}", seed.id.simple());
|
||||
seed.owner = if direction.target_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let choice = Choice {
|
||||
id: seed.id,
|
||||
label: format!("{} · reserved @{}", seed.name, seed.alias),
|
||||
};
|
||||
self.seeds.insert(seed.id, seed);
|
||||
if direction.target_layer() == Layer::Oa {
|
||||
self.oa = Some(choice)
|
||||
} else {
|
||||
self.sa = Some(choice)
|
||||
};
|
||||
}
|
||||
Event::Input(facet, value) => {
|
||||
let (_, which) = parts(&facet).ok_or("Choose artifact metadata.")?;
|
||||
if !compatible(which, &value) || !self.drafts.contains_key(&facet) {
|
||||
return Err(
|
||||
"Choose Set final value for this exact artifact metadata first.".into(),
|
||||
);
|
||||
}
|
||||
if which == ArtifactFacet::Description
|
||||
&& let Raw::Text(text) = &value
|
||||
{
|
||||
self.descriptions
|
||||
.insert(facet.clone(), text_editor::Content::with_text(text));
|
||||
}
|
||||
self.drafts.insert(facet, value);
|
||||
}
|
||||
Event::Description(facet, action) => {
|
||||
let editor = self
|
||||
.descriptions
|
||||
.get_mut(&facet)
|
||||
.ok_or("Choose the artifact description draft first.")?;
|
||||
editor.perform(action);
|
||||
self.drafts.insert(facet, Raw::Text(editor.text()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
pub fn editable(
|
||||
&self,
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
) -> bool {
|
||||
let Some((keys, which)) = parts(facet) else {
|
||||
return false;
|
||||
};
|
||||
key(keys, layer).is_some()
|
||||
&& capture_facet(p, trace.oa, trace.sa, facet).is_ok()
|
||||
&& (which == ArtifactFacet::Presence
|
||||
|| key(keys, layer).is_some_and(|id| {
|
||||
p.artifact_links.contains_key(&id) || self.seeds.contains_key(&id)
|
||||
}))
|
||||
}
|
||||
pub fn propose(
|
||||
&self,
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
decisions: &BTreeMap<FacetSelector, SemanticDecision>,
|
||||
) -> Result<Proposal, String> {
|
||||
let mut result = Proposal {
|
||||
slots: vec![],
|
||||
creations: vec![],
|
||||
candidates: BTreeSet::new(),
|
||||
};
|
||||
let mut pairs: BTreeMap<Id, Vec<(&FacetSelector, SemanticDecision)>> = BTreeMap::new();
|
||||
for (facet, decision) in decisions {
|
||||
if let Some((keys, _)) = parts(facet) {
|
||||
let id = key(keys, direction.target_layer())
|
||||
.ok_or("Choose a destination artifact identity.")?;
|
||||
pairs.entry(id).or_default().push((facet, *decision));
|
||||
}
|
||||
}
|
||||
for (id, selected) in pairs {
|
||||
let original = p.artifact_links.get(&id);
|
||||
let mut record = original.cloned();
|
||||
let mut deletion_origin = None;
|
||||
let owner = if direction.target_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
for (facet, decision) in &selected {
|
||||
let (keys, which) = parts(facet).expect("Artifact");
|
||||
if which != ArtifactFacet::Presence {
|
||||
continue;
|
||||
}
|
||||
if *decision == SemanticDecision::Candidate {
|
||||
return Err("Artifact presence uses Keep or Take; deletion requires explicit source-owner absence.".into());
|
||||
}
|
||||
if *decision == SemanticDecision::TakeSource
|
||||
&& key(keys, direction.source_layer()).is_some()
|
||||
{
|
||||
let fact = capture_facet(p, trace.oa, trace.sa, facet)?;
|
||||
let present = scalar::value(&fact, direction.source_layer())
|
||||
.and_then(|value| value.as_bool())
|
||||
.ok_or("Source artifact attachment unavailable.")?;
|
||||
if !present {
|
||||
record = None;
|
||||
deletion_origin = Some((*facet).clone());
|
||||
} else if record.is_none() {
|
||||
record=Some(self.seeds.get(&id).cloned().ok_or("Reserve a complete destination artifact counterpart before taking its presence.")?);
|
||||
} else if record.as_ref().is_some_and(|a| a.owner != owner) {
|
||||
return Err("Take presence cannot move an artifact from another owner; review its Owner from the initially attached trace.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (facet, decision) in &selected {
|
||||
let (keys, which) = parts(facet).expect("Artifact");
|
||||
if which == ArtifactFacet::Presence {
|
||||
continue;
|
||||
}
|
||||
for (layer, side_owner) in [(Layer::Oa, trace.oa), (Layer::Sa, trace.sa)] {
|
||||
if key(keys, layer)
|
||||
.and_then(|id| p.artifact_links.get(&id))
|
||||
.is_some_and(|a| a.owner != side_owner)
|
||||
{
|
||||
return Err("Artifact metadata must be reviewed from its initially attached owner trace.".into());
|
||||
}
|
||||
}
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let value = if *decision == SemanticDecision::Candidate {
|
||||
self.drafts
|
||||
.get(*facet)
|
||||
.cloned()
|
||||
.ok_or("Choose an explicit final metadata value.")?
|
||||
} else {
|
||||
if key(keys, direction.source_layer()).is_none() {
|
||||
continue;
|
||||
}
|
||||
let source=key(keys,direction.source_layer()).and_then(|id|p.artifact_links.get(&id)).ok_or("Source artifact metadata is absent; keep or set the target metadata explicitly.")?;
|
||||
let mut value = raw(source, which).expect("Metadata");
|
||||
if let Raw::Owner(ref mut owner) = value {
|
||||
let from = source.owner;
|
||||
let target = if direction == TransitionDirection::OaToSa {
|
||||
references.get(&from).copied()
|
||||
} else {
|
||||
let ids: Vec<_> = references
|
||||
.iter()
|
||||
.filter(|(_, sa)| **sa == from)
|
||||
.map(|(oa, _)| *oa)
|
||||
.collect();
|
||||
(ids.len() == 1).then(|| ids[0])
|
||||
}
|
||||
.ok_or("Map the source artifact owner explicitly.")?;
|
||||
*owner = target.to_string();
|
||||
}
|
||||
value
|
||||
};
|
||||
write(
|
||||
record
|
||||
.as_mut()
|
||||
.ok_or("This metadata decision conflicts with absent artifact presence.")?,
|
||||
which,
|
||||
&value,
|
||||
)?;
|
||||
if *decision == SemanticDecision::Candidate {
|
||||
result.candidates.insert(id);
|
||||
}
|
||||
}
|
||||
for (facet, decision) in &selected {
|
||||
let (_, which) = parts(facet).expect("Artifact");
|
||||
result.slots.push((
|
||||
slot(id, which),
|
||||
observation(
|
||||
if *decision == SemanticDecision::KeepTarget {
|
||||
original
|
||||
} else {
|
||||
record.as_ref()
|
||||
},
|
||||
which,
|
||||
),
|
||||
(*facet).clone(),
|
||||
));
|
||||
}
|
||||
if original.is_some() && record.is_none() {
|
||||
for which in METADATA {
|
||||
result.slots.push((
|
||||
slot(id, which),
|
||||
observation(None, which),
|
||||
deletion_origin
|
||||
.clone()
|
||||
.expect("Absent record has an explicit Presence decision"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if original.is_none()
|
||||
&& let Some(record) = record
|
||||
{
|
||||
result.candidates.insert(id);
|
||||
result.creations.push(record);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Saved {
|
||||
Saved {
|
||||
oa: self.oa.as_ref().map(|c| c.id),
|
||||
sa: self.sa.as_ref().map(|c| c.id),
|
||||
seeds: self.seeds.values().cloned().collect(),
|
||||
drafts: self
|
||||
.drafts
|
||||
.iter()
|
||||
.map(|(facet, value)| (facet.clone(), value.clone()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub fn from_recovery(
|
||||
p: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
saved: Saved,
|
||||
) -> Result<Self, String> {
|
||||
if saved.seeds.len().saturating_add(saved.drafts.len())
|
||||
> crate::source_workspace::LIMITS.model.max_records
|
||||
{
|
||||
return Err("Artifact recovery exceeds supported limits.".into());
|
||||
}
|
||||
let mut editor = Self::default();
|
||||
let owner = if direction.target_layer() == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let mut aliases = BTreeSet::new();
|
||||
for seed in saved.seeds {
|
||||
if p.contains(seed.id)
|
||||
|| seed.owner != owner
|
||||
|| seed.alias != format!("artifact_{}", seed.id.simple())
|
||||
|| !aliases.insert(seed.alias.clone())
|
||||
|| editor.seeds.insert(seed.id, seed).is_some()
|
||||
{
|
||||
return Err("Recovered reserved artifact identity, owner or alias is invalid or duplicated.".into());
|
||||
}
|
||||
}
|
||||
for (facet, value) in saved.drafts {
|
||||
let (keys, which) =
|
||||
parts(&facet).ok_or("Recovered artifact draft has a different facet family.")?;
|
||||
if !compatible(which, &value) {
|
||||
return Err("Recovered artifact draft type disagrees with its facet.".into());
|
||||
}
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
if key(keys, layer)
|
||||
.and_then(|id| p.layer_of(id))
|
||||
.is_some_and(|actual| actual != layer)
|
||||
{
|
||||
return Err("Recovered artifact pair has a wrong-layer identity.".into());
|
||||
}
|
||||
}
|
||||
let id = key(keys, direction.target_layer())
|
||||
.ok_or("Recovered artifact draft has no destination identity.")?;
|
||||
if !p.artifact_links.contains_key(&id) && !editor.seeds.contains_key(&id) {
|
||||
return Err(
|
||||
"Recovered artifact draft has no existing or reserved destination record."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if let (ArtifactFacet::Description, Raw::Text(text)) = (which, &value) {
|
||||
editor
|
||||
.descriptions
|
||||
.insert(facet.clone(), text_editor::Content::with_text(text));
|
||||
}
|
||||
if editor.drafts.insert(facet, value).is_some() {
|
||||
return Err("Recovered artifact draft address is duplicated.".into());
|
||||
}
|
||||
}
|
||||
editor.oa = saved.oa.map(|id| editor.choice(p, id));
|
||||
editor.sa = saved.sa.map(|id| editor.choice(p, id));
|
||||
Ok(editor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Independent admission falsifiers: corrupt stored JSON, never private Saved structs.
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
struct Checkpoint {
|
||||
project: Project,
|
||||
state: State,
|
||||
target_owner: Id,
|
||||
source_link: Id,
|
||||
keys: KeyPair<Id>,
|
||||
}
|
||||
|
||||
fn send(state: &mut State, project: &Project, event: RowEvent) {
|
||||
let addressed = state.address_event(event);
|
||||
state.update(project, addressed);
|
||||
}
|
||||
|
||||
fn facet(keys: &KeyPair<Id>, which: ArtifactFacet) -> FacetSelector {
|
||||
FacetSelector::Artifact {
|
||||
keys: keys.clone(),
|
||||
facet: which,
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint(reverse: bool) -> Checkpoint {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Reviewed activity", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Reviewed function", None)
|
||||
.unwrap();
|
||||
let (source_owner, target_owner, source_layer) = if reverse {
|
||||
(sa, oa, Layer::Sa)
|
||||
} else {
|
||||
(oa, sa, Layer::Oa)
|
||||
};
|
||||
let source_link = Id::new_v4();
|
||||
project.artifact_links.insert(
|
||||
source_link,
|
||||
ArtifactLink {
|
||||
id: source_link,
|
||||
alias: "reviewed_requirement".into(),
|
||||
name: "Interlock evidence".into(),
|
||||
description: "First line\r\nSecond line".into(),
|
||||
owner: source_owner,
|
||||
relation: ArtifactRelation::Verifies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: Some(String::new()),
|
||||
},
|
||||
);
|
||||
let trace = super::tests::add_trace(&mut project, oa, sa, "Evidence correspondence");
|
||||
let mut state = State::new(&project, trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&project, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
source_layer,
|
||||
Some(view::choice(&project, source_link)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Stage),
|
||||
);
|
||||
let keys = state.artifacts.pair();
|
||||
for which in [
|
||||
ArtifactFacet::Presence,
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Description,
|
||||
ArtifactFacet::Owner,
|
||||
ArtifactFacet::Relation,
|
||||
ArtifactFacet::Uri,
|
||||
ArtifactFacet::Revision,
|
||||
ArtifactFacet::Fragment,
|
||||
] {
|
||||
let address = facet(&keys, which);
|
||||
send(&mut state, &project, RowEvent::Select(address.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Decide(
|
||||
address,
|
||||
Some(if which == ArtifactFacet::Presence {
|
||||
SemanticDecision::TakeSource
|
||||
} else {
|
||||
SemanticDecision::Candidate
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
for (which, raw) in [
|
||||
(
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner("unfinished owner -".into()),
|
||||
),
|
||||
(
|
||||
ArtifactFacet::Revision,
|
||||
artifacts::Raw::Optional {
|
||||
present: false,
|
||||
text: "retained revision\r\nnext line".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
ArtifactFacet::Fragment,
|
||||
artifacts::Raw::Optional {
|
||||
present: true,
|
||||
text: String::new(),
|
||||
},
|
||||
),
|
||||
] {
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Artifact(artifacts::Event::Input(facet(&keys, which), raw)),
|
||||
);
|
||||
}
|
||||
assert!(!state.can_apply());
|
||||
Checkpoint {
|
||||
project,
|
||||
state,
|
||||
target_owner,
|
||||
source_link,
|
||||
keys,
|
||||
}
|
||||
}
|
||||
|
||||
fn artifacts_json(saved: &mut Value) -> &mut Value {
|
||||
assert_eq!(saved["version"], 2);
|
||||
// Switching direction retains the earlier row. Corrupt the exact active
|
||||
// row's authored payload, regardless of retained rows or their ordering.
|
||||
let active = saved["active"].clone();
|
||||
assert!(!active.is_null());
|
||||
let rows = saved["rows"].as_array_mut().unwrap();
|
||||
assert_eq!(
|
||||
rows.iter().filter(|row| row["address"] == active).count(),
|
||||
1
|
||||
);
|
||||
&mut rows
|
||||
.iter_mut()
|
||||
.find(|row| row["address"] == active)
|
||||
.unwrap()["draft"]["artifacts"]
|
||||
}
|
||||
|
||||
fn owner_draft<'a>(saved: &'a mut Value, keys: &KeyPair<Id>) -> &'a mut Value {
|
||||
let address = serde_json::to_value(facet(keys, ArtifactFacet::Owner)).unwrap();
|
||||
artifacts_json(saved)["drafts"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|entry| entry[0] == address)
|
||||
.expect("authored Owner draft")
|
||||
}
|
||||
|
||||
fn refuses_without_changing_authored_or_accepted_state(
|
||||
case: &str,
|
||||
checkpoint: &mut Checkpoint,
|
||||
forged: Value,
|
||||
) {
|
||||
let before = checkpoint.project.clone();
|
||||
let source = oa_core::language::format(&before);
|
||||
let authored = checkpoint.state.recovery_snapshot().unwrap();
|
||||
assert!(
|
||||
State::from_recovery(&checkpoint.project, forged).is_err(),
|
||||
"accepted forged {case}"
|
||||
);
|
||||
assert_eq!(
|
||||
checkpoint.state.recovery_snapshot().unwrap(),
|
||||
authored,
|
||||
"{case}"
|
||||
);
|
||||
assert!(
|
||||
checkpoint.state.command(&checkpoint.project).is_err(),
|
||||
"invalid raw draft became executable: {case}"
|
||||
);
|
||||
assert_eq!(checkpoint.project, before, "{case}");
|
||||
assert_eq!(
|
||||
oa_core::language::format(&checkpoint.project),
|
||||
source,
|
||||
"{case}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_artifact_reservations_cannot_duplicate_or_claim_an_existing_identity_or_another_owner() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
for case in [
|
||||
"duplicate seed",
|
||||
"existing identity",
|
||||
"wrong owner",
|
||||
"wrong alias",
|
||||
] {
|
||||
let mut forged = saved.clone();
|
||||
let seeds = artifacts_json(&mut forged)["seeds"].as_array_mut().unwrap();
|
||||
assert_eq!(seeds.len(), 1);
|
||||
match case {
|
||||
"duplicate seed" => seeds.push(seeds[0].clone()),
|
||||
"existing identity" => {
|
||||
seeds[0]["id"] = json!(c.source_link);
|
||||
seeds[0]["alias"] = json!(format!("artifact_{}", c.source_link.simple()));
|
||||
}
|
||||
"wrong owner" => seeds[0]["owner"] = json!(c.project.id),
|
||||
"wrong alias" => seeds[0]["alias"] = json!("forged_alias"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
refuses_without_changing_authored_or_accepted_state(case, &mut c, forged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_artifact_raw_variants_duplicate_addresses_and_unknown_fields_are_refused() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
for case in [
|
||||
"wrong raw variant",
|
||||
"duplicate facet",
|
||||
"unknown artifact field",
|
||||
] {
|
||||
let mut forged = saved.clone();
|
||||
match case {
|
||||
"wrong raw variant" => {
|
||||
owner_draft(&mut forged, &c.keys)[1] = json!({"Text": "unfinished owner -"})
|
||||
}
|
||||
"duplicate facet" => {
|
||||
let duplicate = owner_draft(&mut forged, &c.keys).clone();
|
||||
artifacts_json(&mut forged)["drafts"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(duplicate);
|
||||
}
|
||||
"unknown artifact field" => artifacts_json(&mut forged)["execute"] = json!(true),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
refuses_without_changing_authored_or_accepted_state(case, &mut c, forged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_artifact_draft_cannot_target_an_unreserved_or_non_artifact_identity() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
for (case, destination) in [
|
||||
("nonexistent destination", Id::new_v4()),
|
||||
("element destination", c.target_owner),
|
||||
] {
|
||||
let mut forged = saved.clone();
|
||||
let mut keys = c.keys.clone();
|
||||
if reverse {
|
||||
keys.oa = Some(destination);
|
||||
} else {
|
||||
keys.sa = Some(destination);
|
||||
}
|
||||
owner_draft(&mut forged, &c.keys)[0] =
|
||||
serde_json::to_value(facet(&keys, ArtifactFacet::Owner)).unwrap();
|
||||
refuses_without_changing_authored_or_accepted_state(case, &mut c, forged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legitimate_invalid_artifact_checkpoint_round_trips_and_prepares_only_after_explicit_repair() {
|
||||
for reverse in [false, true] {
|
||||
let mut c = checkpoint(reverse);
|
||||
let accepted = c.project.clone();
|
||||
let accepted_source = oa_core::language::format(&accepted);
|
||||
let saved = c.state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&c.project, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(
|
||||
owner_draft(&mut saved.clone(), &c.keys)[1],
|
||||
json!({"Owner": "unfinished owner -"})
|
||||
);
|
||||
assert!(!restored.can_apply());
|
||||
assert!(restored.command(&c.project).is_err());
|
||||
let repair = RowEvent::Artifact(artifacts::Event::Input(
|
||||
facet(&c.keys, ArtifactFacet::Owner),
|
||||
artifacts::Raw::Owner(c.target_owner.to_string()),
|
||||
));
|
||||
send(&mut restored, &c.project, repair.clone());
|
||||
send(&mut c.state, &c.project, repair);
|
||||
let restored_command = restored.command(&c.project).unwrap();
|
||||
let uninterrupted_command = c.state.command(&c.project).unwrap();
|
||||
let prepared = prepare_command(&c.project, &restored_command)
|
||||
.unwrap()
|
||||
.project;
|
||||
let uninterrupted = prepare_command(&c.project, &uninterrupted_command)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert_eq!(prepared, uninterrupted);
|
||||
assert_eq!(
|
||||
oa_core::language::format(&prepared),
|
||||
oa_core::language::format(&uninterrupted)
|
||||
);
|
||||
let destination = if reverse {
|
||||
c.keys.oa.unwrap()
|
||||
} else {
|
||||
c.keys.sa.unwrap()
|
||||
};
|
||||
assert_eq!(prepared.artifact_links[&destination].revision, None);
|
||||
assert_eq!(
|
||||
prepared.artifact_links[&destination].fragment,
|
||||
Some(String::new())
|
||||
);
|
||||
assert_eq!(c.project, accepted);
|
||||
assert_eq!(oa_core::language::format(&c.project), accepted_source);
|
||||
assert!(
|
||||
!c.project.contains(destination),
|
||||
"preparation must not execute creation"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
//! Core lifecycle boundaries and graphical artifact review oracles.
|
||||
use super::*;
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owners: [Id; 2],
|
||||
other: [Id; 2],
|
||||
links: [Id; 2],
|
||||
trace: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut other = vec![];
|
||||
let mut links = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Reviewed owner", None).unwrap();
|
||||
let moved = p.add_element(kind, layer, "Other owner", None).unwrap();
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: format!("artifact_{}", id.simple()),
|
||||
name: "Interlock requirement".into(),
|
||||
description: "Exact\r\nengineering rationale".into(),
|
||||
owner,
|
||||
relation: ArtifactRelation::Satisfies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: Some(String::new()),
|
||||
},
|
||||
);
|
||||
owners.push(owner);
|
||||
other.push(moved);
|
||||
links.push(id);
|
||||
}
|
||||
let trace = super::tests::add_trace(&mut p, owners[0], owners[1], "Requirement correspondence");
|
||||
Fixture {
|
||||
p,
|
||||
owners: [owners[0], owners[1]],
|
||||
other: [other[0], other[1]],
|
||||
links: [links[0], links[1]],
|
||||
trace,
|
||||
}
|
||||
}
|
||||
fn selector(keys: &KeyPair<Id>, facet: ArtifactFacet) -> FacetSelector {
|
||||
FacetSelector::Artifact {
|
||||
keys: keys.clone(),
|
||||
facet,
|
||||
}
|
||||
}
|
||||
fn request(
|
||||
f: &Fixture,
|
||||
reverse: bool,
|
||||
keys: &KeyPair<Id>,
|
||||
decisions: Vec<(ArtifactFacet, SemanticDecision)>,
|
||||
) -> SemanticTransitionRequest {
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let mut transition = TransitionRequest::new(
|
||||
if reverse {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
},
|
||||
vec![f.owners[source]],
|
||||
);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: f.owners[source],
|
||||
target: Some(f.owners[target]),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut r = SemanticTransitionRequest::new(transition);
|
||||
r.traces.insert(f.owners[source], f.trace);
|
||||
r.references.insert(f.owners[0], f.owners[1]);
|
||||
if let Some((oa, sa)) = keys.oa.zip(keys.sa) {
|
||||
r.references.insert(oa, sa);
|
||||
}
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: f.owners[source],
|
||||
resolutions: decisions
|
||||
.into_iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: selector(keys, facet),
|
||||
decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
const METADATA: [ArtifactFacet; 7] = [
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Description,
|
||||
ArtifactFacet::Owner,
|
||||
ArtifactFacet::Relation,
|
||||
ArtifactFacet::Uri,
|
||||
ArtifactFacet::Revision,
|
||||
ArtifactFacet::Fragment,
|
||||
];
|
||||
#[test]
|
||||
fn artifact_creation_and_identified_source_deletion_follow_core_metadata_and_history_boundary_both_directions()
|
||||
{
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
f.p.artifact_links.remove(&f.links[target]);
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut candidate = f.p.artifact_links[&f.links[source]].clone();
|
||||
candidate.id = f.links[target];
|
||||
candidate.alias = format!("artifact_{}", candidate.id.simple());
|
||||
candidate.owner = f.owners[target];
|
||||
let mut r = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::TakeSource)],
|
||||
);
|
||||
r.candidates
|
||||
.push(SemanticRecord::Artifact(candidate.clone()));
|
||||
assert!(
|
||||
!plan_semantic_transition(&f.p, r.clone())
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty()
|
||||
);
|
||||
r.reviews[0]
|
||||
.resolutions
|
||||
.extend(METADATA.map(|facet| FacetResolution {
|
||||
facet: selector(&keys, facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
}));
|
||||
let plan = plan_semantic_transition(&f.p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
f.p = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(f.p.artifact_links[&f.links[target]], candidate);
|
||||
assert_eq!(
|
||||
f.p.traces[&f.trace]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.len(),
|
||||
8
|
||||
);
|
||||
f.p = prepare_command(&f.p, &Command::Delete(f.links[source]))
|
||||
.unwrap()
|
||||
.project;
|
||||
let deletion = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::TakeSource)],
|
||||
);
|
||||
let plan = plan_semantic_transition(&f.p, deletion).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
assert_eq!(plan.reviews[0].retired.len(), 7);
|
||||
let after = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert!(!after.artifact_links.contains_key(&f.links[target]));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_candidate_is_a_complete_record_not_a_tombstone_or_unattached_creation_shortcut() {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let target = usize::from(!reverse);
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut r = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::Candidate)],
|
||||
);
|
||||
let mut moved = f.p.artifact_links[&f.links[target]].clone();
|
||||
moved.owner = f.other[target];
|
||||
r.candidates.push(SemanticRecord::Artifact(moved));
|
||||
assert!(
|
||||
!plan_semantic_transition(&f.p, r)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty(),
|
||||
"A moved record cannot serve as a deletion tombstone"
|
||||
);
|
||||
let mut candidate = f.p.artifact_links[&f.links[target]].clone();
|
||||
candidate.id = Id::new_v4();
|
||||
candidate.alias = format!("artifact_{}", candidate.id.simple());
|
||||
f.p.artifact_links.clear();
|
||||
let keys = if reverse {
|
||||
KeyPair {
|
||||
oa: Some(candidate.id),
|
||||
sa: None,
|
||||
}
|
||||
} else {
|
||||
KeyPair {
|
||||
oa: None,
|
||||
sa: Some(candidate.id),
|
||||
}
|
||||
};
|
||||
let mut decisions = vec![(ArtifactFacet::Presence, SemanticDecision::Candidate)];
|
||||
decisions.extend(METADATA.map(|facet| (facet, SemanticDecision::Candidate)));
|
||||
let mut r = request(&f, reverse, &keys, decisions);
|
||||
r.candidates.push(SemanticRecord::Artifact(candidate));
|
||||
assert!(
|
||||
!plan_semantic_transition(&f.p, r)
|
||||
.unwrap()
|
||||
.conflicts
|
||||
.is_empty(),
|
||||
"Metadata has no applicable initial source or target record"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
let event = state.address_event(event);
|
||||
state.update(p, event);
|
||||
}
|
||||
fn open(f: &Fixture, reverse: bool, keys: &KeyPair<Id>) -> State {
|
||||
let mut state = State::new(&f.p, f.trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&f.p, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
Layer::Oa,
|
||||
keys.oa.map(|id| view::choice(&f.p, id)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
Layer::Sa,
|
||||
keys.sa.map(|id| view::choice(&f.p, id)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Stage),
|
||||
);
|
||||
state
|
||||
}
|
||||
fn decide(
|
||||
state: &mut State,
|
||||
p: &Project,
|
||||
keys: &KeyPair<Id>,
|
||||
which: ArtifactFacet,
|
||||
decision: SemanticDecision,
|
||||
) {
|
||||
let facet = selector(keys, which);
|
||||
send(state, p, RowEvent::Select(facet.clone()));
|
||||
send(state, p, RowEvent::Decide(facet, Some(decision)));
|
||||
}
|
||||
fn input(
|
||||
state: &mut State,
|
||||
p: &Project,
|
||||
keys: &KeyPair<Id>,
|
||||
which: ArtifactFacet,
|
||||
value: artifacts::Raw,
|
||||
) {
|
||||
send(
|
||||
state,
|
||||
p,
|
||||
RowEvent::Artifact(artifacts::Event::Input(selector(keys, which), value)),
|
||||
);
|
||||
}
|
||||
fn errors(state: &State) -> String {
|
||||
format!(
|
||||
"{:?} / {:?}",
|
||||
state.error,
|
||||
state.plan.as_ref().map(|p| &p.conflicts)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn artifact_gui_reserves_recovers_and_creates_only_after_all_seven_metadata_reviews_both_directions()
|
||||
{
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let target = usize::from(!reverse);
|
||||
f.p.artifact_links.remove(&f.links[target]);
|
||||
let keys = if reverse {
|
||||
KeyPair {
|
||||
oa: None,
|
||||
sa: Some(f.links[1]),
|
||||
}
|
||||
} else {
|
||||
KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: None,
|
||||
}
|
||||
};
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Artifact(artifacts::Event::Stage),
|
||||
);
|
||||
let keys = state.artifacts.pair();
|
||||
let id = artifacts::key(&keys, state.direction.target_layer()).unwrap();
|
||||
assert!(!f.p.contains(id));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut state = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(state.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(state.artifacts.pair(), keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
for facet in METADATA {
|
||||
decide(&mut state, &f.p, &keys, facet, SemanticDecision::Candidate);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let mut direct = request(
|
||||
&f,
|
||||
reverse,
|
||||
&keys,
|
||||
vec![(ArtifactFacet::Presence, SemanticDecision::TakeSource)],
|
||||
);
|
||||
direct.reviews[0]
|
||||
.resolutions
|
||||
.extend(METADATA.map(|facet| FacetResolution {
|
||||
facet: selector(&keys, facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
}));
|
||||
direct
|
||||
.candidates
|
||||
.push(SemanticRecord::Artifact(state.artifacts.seeds[&id].clone()));
|
||||
let plan = plan_semantic_transition(&f.p, direct).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let expected = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(state.proposed.as_ref(), Some(&expected));
|
||||
assert_eq!(expected.artifact_links[&id].revision, None);
|
||||
assert_eq!(expected.artifact_links[&id].fragment, Some(String::new()));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_move_metadata_none_empty_and_invalid_raw_recovery_are_independent_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let f = fixture();
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let target = usize::from(!reverse);
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner(f.other[target].to_string()),
|
||||
);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Revision,
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Revision,
|
||||
artifacts::Raw::Optional {
|
||||
present: true,
|
||||
text: String::new(),
|
||||
},
|
||||
);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Fragment,
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Fragment,
|
||||
artifacts::Raw::Optional {
|
||||
present: false,
|
||||
text: "retained fragment draft".into(),
|
||||
},
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let result = state.proposed.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
result.artifact_links[&f.links[target]].owner,
|
||||
f.other[target]
|
||||
);
|
||||
assert_eq!(
|
||||
result.artifact_links[&f.links[target]].revision,
|
||||
Some(String::new())
|
||||
);
|
||||
assert_eq!(result.artifact_links[&f.links[target]].fragment, None);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::KeepTarget,
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Decide(selector(&keys, ArtifactFacet::Presence), None),
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
input(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner("-".into()),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
let address = state.active_address.unwrap();
|
||||
state.update(&f.p, Event::Include(address, false));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(
|
||||
restored.artifacts.drafts[&selector(&keys, ArtifactFacet::Owner)],
|
||||
artifacts::Raw::Owner("-".into())
|
||||
);
|
||||
assert_eq!(
|
||||
restored.artifacts.drafts[&selector(&keys, ArtifactFacet::Fragment)],
|
||||
artifacts::Raw::Optional {
|
||||
present: false,
|
||||
text: "retained fragment draft".into()
|
||||
}
|
||||
);
|
||||
restored.update(&f.p, Event::Include(address, true));
|
||||
input(
|
||||
&mut restored,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Owner,
|
||||
artifacts::Raw::Owner(f.other[target].to_string()),
|
||||
);
|
||||
assert!(restored.can_apply(), "{}", errors(&restored));
|
||||
assert_eq!(f.p.artifact_links[&f.links[target]].owner, f.owners[target]);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_moved_source_absence_deletes_but_destination_only_take_retains_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture();
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
f.p.artifact_links.get_mut(&f.links[source]).unwrap().owner = f.other[source];
|
||||
// No selected pair or historical baseline can smuggle this option into
|
||||
// the list: a moved-away existing link must be directly selectable.
|
||||
let choices = artifacts::Editor::default().choices(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
if reverse { Layer::Sa } else { Layer::Oa },
|
||||
);
|
||||
let moved = choices
|
||||
.iter()
|
||||
.find(|choice| choice.id == f.links[source])
|
||||
.expect("Moved-away source is a real picker option");
|
||||
assert!(moved.label.contains("Other owner"), "{}", moved.label);
|
||||
assert!(
|
||||
moved.label.contains("not attached to reviewed owner"),
|
||||
"{}",
|
||||
moved.label
|
||||
);
|
||||
assert!(!choices.iter().any(|choice| choice.id == f.links[target]));
|
||||
let mut neutral = f.p.clone();
|
||||
neutral
|
||||
.artifact_links
|
||||
.get_mut(&f.links[source])
|
||||
.unwrap()
|
||||
.owner = neutral.id;
|
||||
let choices = artifacts::Editor::default().choices(
|
||||
&neutral,
|
||||
&neutral.traces[&f.trace],
|
||||
if reverse { Layer::Sa } else { Layer::Oa },
|
||||
);
|
||||
assert!(
|
||||
choices.iter().any(|choice| choice.id == f.links[source]),
|
||||
"A link moved to a neutral project owner still has an explicit source-owner absence"
|
||||
);
|
||||
|
||||
let mut keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(
|
||||
!state
|
||||
.proposed
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.artifact_links
|
||||
.contains_key(&f.links[target])
|
||||
);
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().artifact_links[&f.links[source]],
|
||||
f.p.artifact_links[&f.links[source]]
|
||||
);
|
||||
if reverse {
|
||||
keys.sa = None
|
||||
} else {
|
||||
keys.oa = None
|
||||
};
|
||||
let mut state = open(&f, reverse, &keys);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
&keys,
|
||||
ArtifactFacet::Presence,
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().artifact_links[&f.links[target]],
|
||||
f.p.artifact_links[&f.links[target]]
|
||||
);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn artifact_metadata_apply_and_exact_history_reset_are_one_source_preserving_undo_redo() {
|
||||
use crate::app::App;
|
||||
let f = fixture();
|
||||
let keys = KeyPair {
|
||||
oa: Some(f.links[0]),
|
||||
sa: Some(f.links[1]),
|
||||
};
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Artifact rationale stays verbatim\r\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
super::tests::start(&mut app, f.trace);
|
||||
super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::EditPair(selector(&keys, ArtifactFacet::Name)),
|
||||
);
|
||||
super::tests::send(&mut app, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
super::tests::decide(
|
||||
&mut app,
|
||||
selector(&keys, ArtifactFacet::Name),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::Artifact(artifacts::Event::Input(
|
||||
selector(&keys, ArtifactFacet::Name),
|
||||
artifacts::Raw::Text("Reviewed interlock requirement".into()),
|
||||
)),
|
||||
);
|
||||
assert!(
|
||||
super::tests::state(&app).can_apply(),
|
||||
"{}",
|
||||
errors(super::tests::state(&app))
|
||||
);
|
||||
let expected = super::tests::state(&app).proposed.clone().unwrap();
|
||||
super::tests::apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.snapshot();
|
||||
assert!(
|
||||
after.bundle.files[&after.bundle.entry]
|
||||
.starts_with("// Artifact rationale stays verbatim\r\n")
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
super::tests::start(&mut app, f.trace);
|
||||
super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::Reset(selector(&keys, ArtifactFacet::Name), true),
|
||||
);
|
||||
assert!(
|
||||
super::tests::state(&app).can_apply(),
|
||||
"{}",
|
||||
errors(super::tests::state(&app))
|
||||
);
|
||||
super::tests::apply(&mut app);
|
||||
assert_eq!(app.project().artifact_links, expected.artifact_links);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use super::artifacts::{Editor, Event, Raw};
|
||||
use super::*;
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text, text_editor, text_input},
|
||||
};
|
||||
pub(super) fn pairs<'a>(editor: &'a Editor, p: &'a Project, trace: &'a Trace) -> View<'a, Event> {
|
||||
let mut content=column![text("Artifact links and metadata").size(16),text("Pair links attached to these reviewed owners. Presence is attachment to the owner; taking identified source absence deletes the destination link, while a destination-only link is retained.").size(12),text_input("Filter artifact identities",&editor.search).on_input(Event::Search).padding(7)].spacing(9);
|
||||
for (layer, label, selected) in [
|
||||
(Layer::Oa, "Operational", editor.oa.clone()),
|
||||
(Layer::Sa, "System", editor.sa.clone()),
|
||||
] {
|
||||
content = content.push(text(label).size(12)).push(
|
||||
row![
|
||||
pick_list(editor.choices(p, trace, layer), selected, move |v| {
|
||||
Event::Pick(layer, Some(v))
|
||||
})
|
||||
.placeholder("Choose exact artifact identity")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
button("No identity")
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content.push(row![button("Reserve a new destination counterpart").padding(7).on_press(Event::Reserve),button("Review this exact artifact pair").padding(7).on_press(Event::Stage)].spacing(7).wrap()).push(text("Reservation retains a fresh identity and seeds metadata from the source. It creates nothing until Presence and all seven metadata facets are explicitly reviewed and the combined Apply succeeds. New unrelated links use ordinary artifact creation.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
p: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
) -> Option<View<'a, Event>> {
|
||||
let value = editor.drafts.get(facet)?;
|
||||
let (_, which) = artifacts::parts(facet)?;
|
||||
let mut content =
|
||||
column![text(format!("Final artifact {}", artifacts::field(which)?)).size(12)].spacing(7);
|
||||
match value {
|
||||
Raw::Text(_) if which == ArtifactFacet::Description => {
|
||||
let draft = editor.descriptions.get(facet)?;
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_editor(draft)
|
||||
.height(110)
|
||||
.on_action(move |action| Event::Description(facet.clone(), action)),
|
||||
);
|
||||
}
|
||||
Raw::Text(value) => {
|
||||
let facet = facet.clone();
|
||||
content = content.push(
|
||||
text_input("Exact text", value)
|
||||
.on_input(move |value| Event::Input(facet.clone(), Raw::Text(value)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Owner(value) => {
|
||||
let mut choices: Vec<_> = p
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| Choice {
|
||||
id,
|
||||
label: format!("{} · @{alias}", p.named(id)),
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|c| c.label.to_lowercase());
|
||||
let selected = value.parse::<Id>().ok().map(|id| view::choice(p, id));
|
||||
let raw_facet = facet.clone();
|
||||
let pick_facet = facet.clone();
|
||||
content = content
|
||||
.push(
|
||||
pick_list(choices, selected, move |v: Choice| {
|
||||
Event::Input(pick_facet.clone(), Raw::Owner(v.id.to_string()))
|
||||
})
|
||||
.placeholder("Explicit final owner")
|
||||
.width(Fill)
|
||||
.padding(7),
|
||||
)
|
||||
.push(
|
||||
text_input("Owner identity (retains rejected input)", value)
|
||||
.on_input(move |v| Event::Input(raw_facet.clone(), Raw::Owner(v)))
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Relation(value) => {
|
||||
let facet = facet.clone();
|
||||
let variants = [
|
||||
ArtifactRelation::References,
|
||||
ArtifactRelation::Satisfies,
|
||||
ArtifactRelation::Verifies,
|
||||
ArtifactRelation::Implements,
|
||||
ArtifactRelation::DerivedFrom,
|
||||
];
|
||||
let choices: Vec<_> = variants.into_iter().map(RelationChoice).collect();
|
||||
content = content.push(
|
||||
pick_list(choices, Some(RelationChoice(*value)), move |v| {
|
||||
Event::Input(facet.clone(), Raw::Relation(v.0))
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: value,
|
||||
} => {
|
||||
let toggle_facet = facet.clone();
|
||||
let retained = value.clone();
|
||||
let input_facet = facet.clone();
|
||||
let enabled = *present;
|
||||
content = content
|
||||
.push(
|
||||
checkbox(*present)
|
||||
.label("Present (checked with empty text means an explicit empty value)")
|
||||
.on_toggle(move |present| {
|
||||
Event::Input(
|
||||
toggle_facet.clone(),
|
||||
Raw::Optional {
|
||||
present,
|
||||
text: retained.clone(),
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
.push(
|
||||
text_input("Retained optional text", value)
|
||||
.on_input(move |text| {
|
||||
Event::Input(
|
||||
input_facet.clone(),
|
||||
Raw::Optional {
|
||||
present: enabled,
|
||||
text,
|
||||
},
|
||||
)
|
||||
})
|
||||
.padding(7),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(content.into())
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct RelationChoice(ArtifactRelation);
|
||||
impl std::fmt::Display for RelationChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{:?}", self.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Existing-record child/incident editing with independent exact-field drafts.
|
||||
//! Every proposal names its actual Parent or relationship endpoint slot.
|
||||
use super::*;
|
||||
use crate::forms::RecoveryDraft;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum Family {
|
||||
#[default]
|
||||
Child,
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
impl std::fmt::Display for Family {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::Child => "Owned children",
|
||||
Self::Source => "Relationships at source",
|
||||
Self::Target => "Relationships at target",
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Family(Family),
|
||||
Pick(Layer, Option<Choice>),
|
||||
Search(String),
|
||||
Stage,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct PairDraft {
|
||||
oa: Option<Choice>,
|
||||
sa: Option<Choice>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct Editor {
|
||||
pub family: Family,
|
||||
pub search: String,
|
||||
pairs: BTreeMap<Family, PairDraft>,
|
||||
pub drafts: BTreeMap<FacetSelector, Draft>,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Saved {
|
||||
family: Family,
|
||||
pairs: Vec<(Family, Option<Id>, Option<Id>)>,
|
||||
drafts: Vec<(FacetSelector, RecoveryDraft)>,
|
||||
}
|
||||
pub struct Proposal {
|
||||
pub slot: (Id, FacetSelector),
|
||||
pub path: String,
|
||||
pub record: Value,
|
||||
}
|
||||
pub(super) fn family(facet: &FacetSelector) -> Option<Family> {
|
||||
Some(match facet {
|
||||
FacetSelector::Child(_) => Family::Child,
|
||||
FacetSelector::Incident {
|
||||
end: IncidentEnd::Source,
|
||||
..
|
||||
} => Family::Source,
|
||||
FacetSelector::Incident {
|
||||
end: IncidentEnd::Target,
|
||||
..
|
||||
} => Family::Target,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
pub(super) fn supported(facet: &FacetSelector) -> bool {
|
||||
family(facet).is_some()
|
||||
}
|
||||
fn keys(facet: &FacetSelector) -> Option<&KeyPair<Id>> {
|
||||
match facet {
|
||||
FacetSelector::Child(keys) | FacetSelector::Incident { keys, .. } => Some(keys),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub(super) fn key(facet: &FacetSelector, layer: Layer) -> Option<Id> {
|
||||
let keys = keys(facet)?;
|
||||
if layer == Layer::Oa { keys.oa } else { keys.sa }
|
||||
}
|
||||
pub(super) fn explicit_pair(facet: &FacetSelector) -> Option<(Id, Id)> {
|
||||
let keys = keys(facet)?;
|
||||
keys.oa.zip(keys.sa)
|
||||
}
|
||||
pub(super) fn actual_slot(facet: &FacetSelector, layer: Layer) -> Option<(Id, FacetSelector)> {
|
||||
let id = key(facet, layer)?;
|
||||
Some((
|
||||
id,
|
||||
match family(facet)? {
|
||||
Family::Child => FacetSelector::Header(HeaderFacet::Parent),
|
||||
Family::Source => FacetSelector::Relation(RelationFacet::Source),
|
||||
Family::Target => FacetSelector::Relation(RelationFacet::Target),
|
||||
},
|
||||
))
|
||||
}
|
||||
pub(super) fn path(project: &Project, facet: &FacetSelector, layer: Layer) -> Option<String> {
|
||||
let (id, slot) = actual_slot(facet, layer)?;
|
||||
if project.layer_of(id) != Some(layer) {
|
||||
return None;
|
||||
}
|
||||
// Child keys cannot accidentally name a relationship (and vice versa).
|
||||
match family(facet)? {
|
||||
Family::Child if !project.elements.contains_key(&id) => return None,
|
||||
Family::Source | Family::Target if !project.relations.contains_key(&id) => return None,
|
||||
_ => {}
|
||||
}
|
||||
scalar::binding(project, id, &slot)
|
||||
}
|
||||
pub(super) fn label(facet: &FacetSelector) -> &'static str {
|
||||
match family(facet) {
|
||||
Some(Family::Child) => "Final parent of the destination child",
|
||||
Some(Family::Source) => "Final source of the destination relationship",
|
||||
Some(Family::Target) => "Final target of the destination relationship",
|
||||
None => "Unavailable attachment",
|
||||
}
|
||||
}
|
||||
impl Editor {
|
||||
pub fn selection(&self, layer: Layer) -> Option<&Choice> {
|
||||
let pair = self.pairs.get(&self.family)?;
|
||||
if layer == Layer::Oa {
|
||||
pair.oa.as_ref()
|
||||
} else {
|
||||
pair.sa.as_ref()
|
||||
}
|
||||
}
|
||||
pub fn update(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
event: Event,
|
||||
) -> Result<Option<FacetSelector>, String> {
|
||||
match event {
|
||||
Event::Family(family) => self.family = family,
|
||||
Event::Search(search) => self.search = search,
|
||||
Event::Pick(layer, choice) => {
|
||||
let pair = self.pairs.entry(self.family).or_default();
|
||||
if layer == Layer::Oa {
|
||||
pair.oa = choice;
|
||||
} else {
|
||||
pair.sa = choice;
|
||||
}
|
||||
}
|
||||
Event::Stage => {
|
||||
let keys = KeyPair {
|
||||
oa: self.selection(Layer::Oa).map(|c| c.id),
|
||||
sa: self.selection(Layer::Sa).map(|c| c.id),
|
||||
};
|
||||
let facet = match self.family {
|
||||
Family::Child => FacetSelector::Child(keys),
|
||||
Family::Source => FacetSelector::Incident {
|
||||
keys,
|
||||
end: IncidentEnd::Source,
|
||||
},
|
||||
Family::Target => FacetSelector::Incident {
|
||||
keys,
|
||||
end: IncidentEnd::Target,
|
||||
},
|
||||
};
|
||||
for (id, expected) in [
|
||||
(key(&facet, Layer::Oa), Layer::Oa),
|
||||
(key(&facet, Layer::Sa), Layer::Sa),
|
||||
] {
|
||||
if id
|
||||
.and_then(|id| project.layer_of(id))
|
||||
.is_some_and(|actual| actual != expected)
|
||||
{
|
||||
return Err(
|
||||
"Attachment identities must match their Operational or System side."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
capture_facet(project, trace.oa, trace.sa, &facet)?;
|
||||
return Ok(Some(facet));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
pub fn select_pair(&mut self, project: &Project, facet: &FacetSelector) -> Result<(), String> {
|
||||
self.family = family(facet).ok_or("Choose a child or incident relationship address.")?;
|
||||
let keys = keys(facet).expect("Closed family");
|
||||
self.pairs.insert(
|
||||
self.family,
|
||||
PairDraft {
|
||||
oa: keys.oa.map(|id| view::choice(project, id)),
|
||||
sa: keys.sa.map(|id| view::choice(project, id)),
|
||||
},
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
pub fn ensure(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
facet: &FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Result<(), String> {
|
||||
let id = key(facet, layer).ok_or("Choose an existing destination attachment identity.")?;
|
||||
path(project, facet, layer).ok_or("The destination attachment record is absent or has a different kind. Creation needs an explicit transition mapping.")?;
|
||||
if !self.drafts.contains_key(facet) {
|
||||
self.drafts.insert(
|
||||
facet.clone(),
|
||||
Draft::from_project(project, id)
|
||||
.ok_or("Destination attachment record is absent.")?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn choices(&self, project: &Project, trace: &Trace, layer: Layer) -> Vec<Choice> {
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let owner_kind = project.elements.get(&owner).map(Element::kind);
|
||||
let mut ids: BTreeSet<_> = match self.family {
|
||||
Family::Child => project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& e.id != owner
|
||||
&& (e.parent == Some(owner)
|
||||
|| owner_kind.is_some_and(|kind| {
|
||||
oa_core::validation::parent_permits(kind, e.kind())
|
||||
}))
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect(),
|
||||
Family::Source | Family::Target => project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| {
|
||||
if project.layer_of(relation.id) != Some(layer) {
|
||||
return false;
|
||||
}
|
||||
let (a, b) = relation.body.endpoints();
|
||||
let (current, opposite) = if self.family == Family::Source {
|
||||
(a, b)
|
||||
} else {
|
||||
(b, a)
|
||||
};
|
||||
if current == owner {
|
||||
return true;
|
||||
}
|
||||
match (owner_kind, project.elements.get(&opposite)) {
|
||||
(Some(owner_kind), Some(opposite)) => {
|
||||
if self.family == Family::Source {
|
||||
relation.kind().permits(owner_kind, opposite.kind())
|
||||
} else {
|
||||
relation.kind().permits(opposite.kind(), owner_kind)
|
||||
}
|
||||
}
|
||||
// Rare relationship-to-relationship endpoints use the ordinary
|
||||
// closed property picker rather than assuming element kinds.
|
||||
_ => {
|
||||
let Some(draft) = Draft::from_project(project, relation.id) else {
|
||||
return false;
|
||||
};
|
||||
let facet = FacetSelector::Relation(if self.family == Family::Source {
|
||||
RelationFacet::Source
|
||||
} else {
|
||||
RelationFacet::Target
|
||||
});
|
||||
let Some(path) = scalar::binding(project, relation.id, &facet) else {
|
||||
return false;
|
||||
};
|
||||
crate::forms::reference_choices(&draft, project, &path, Id::nil())
|
||||
.iter()
|
||||
.any(|choice| choice.id == owner)
|
||||
}
|
||||
}
|
||||
})
|
||||
.map(|relation| relation.id)
|
||||
.collect(),
|
||||
};
|
||||
if let Some(selected) = self.selection(layer) {
|
||||
ids.insert(selected.id);
|
||||
}
|
||||
let search = self.search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| view::choice(project, id))
|
||||
.filter(|choice| {
|
||||
search.is_empty()
|
||||
|| choice.label.to_lowercase().contains(&search)
|
||||
|| self
|
||||
.selection(layer)
|
||||
.is_some_and(|selected| selected.id == choice.id)
|
||||
})
|
||||
.collect();
|
||||
choices.sort_by_cached_key(|choice| choice.label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
pub fn propose(
|
||||
&self,
|
||||
project: &Project,
|
||||
trace: &Trace,
|
||||
direction: TransitionDirection,
|
||||
facet: &FacetSelector,
|
||||
decision: SemanticDecision,
|
||||
form_context: bool,
|
||||
) -> Result<Proposal, String> {
|
||||
let layer = direction.target_layer();
|
||||
let slot = actual_slot(facet, layer)
|
||||
.ok_or("Choose the destination attachment identity explicitly.")?;
|
||||
let path = path(project, facet, layer).ok_or(
|
||||
"Only existing destination child and relationship records are supported here.",
|
||||
)?;
|
||||
let mut record = Draft::from_project(project, slot.0)
|
||||
.ok_or("The attached record is absent.")?
|
||||
.value;
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
let value = match decision {
|
||||
SemanticDecision::KeepTarget => record.pointer(&path).expect("Closed path").clone(),
|
||||
SemanticDecision::Candidate => {
|
||||
let draft = self
|
||||
.drafts
|
||||
.get(facet)
|
||||
.ok_or("Set a final attachment value first.")?;
|
||||
let supplied = match draft.materialize_scopes(std::slice::from_ref(&path)) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("The attachment's exact field is unavailable.")?
|
||||
}
|
||||
SemanticDecision::TakeSource if key(facet, direction.source_layer()).is_none() => {
|
||||
record.pointer(&path).expect("Closed path").clone()
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let source = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let present = scalar::value(&source, direction.source_layer()).and_then(|value| value.as_bool()).ok_or("Source attachment does not apply; review an existing owner correspondence.")?;
|
||||
if present {
|
||||
serde_json::to_value(owner).expect("Id")
|
||||
} else if family(facet) == Some(Family::Child) {
|
||||
if record.pointer(&path) == Some(&serde_json::to_value(owner).expect("Id")) {
|
||||
Value::Null
|
||||
} else {
|
||||
record.pointer(&path).expect("Closed path").clone()
|
||||
}
|
||||
} else {
|
||||
return Err("Taking an absent relationship endpoint is incomplete. Choose Set final value and an explicit replacement endpoint.".into());
|
||||
}
|
||||
}
|
||||
};
|
||||
*record.pointer_mut(&path).expect("Closed path") = value;
|
||||
Ok(Proposal { slot, path, record })
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Saved {
|
||||
Saved {
|
||||
family: self.family,
|
||||
pairs: self
|
||||
.pairs
|
||||
.iter()
|
||||
.map(|(family, pair)| {
|
||||
(
|
||||
*family,
|
||||
pair.oa.as_ref().map(|c| c.id),
|
||||
pair.sa.as_ref().map(|c| c.id),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
drafts: self
|
||||
.drafts
|
||||
.iter()
|
||||
.map(|(facet, draft)| (facet.clone(), draft.recovery_snapshot()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
pub fn from_recovery(project: &Project, layer: Layer, saved: Saved) -> Result<Self, String> {
|
||||
if saved.drafts.len() > crate::source_workspace::LIMITS.model.max_records
|
||||
|| saved.pairs.len() > 3
|
||||
{
|
||||
return Err("Recovered attachment drafts exceed the supported limits.".into());
|
||||
}
|
||||
let mut editor = Self {
|
||||
family: saved.family,
|
||||
..Self::default()
|
||||
};
|
||||
for (family, oa, sa) in saved.pairs {
|
||||
for (id, layer) in [(oa, Layer::Oa), (sa, Layer::Sa)] {
|
||||
if id
|
||||
.and_then(|id| project.layer_of(id))
|
||||
.is_some_and(|actual| actual != layer)
|
||||
{
|
||||
return Err("Recovered attachment picker has a wrong-layer identity.".into());
|
||||
}
|
||||
}
|
||||
if editor
|
||||
.pairs
|
||||
.insert(
|
||||
family,
|
||||
PairDraft {
|
||||
oa: oa.map(|id| view::choice(project, id)),
|
||||
sa: sa.map(|id| view::choice(project, id)),
|
||||
},
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
return Err("Recovered attachment picker family is duplicated.".into());
|
||||
}
|
||||
}
|
||||
for (facet, saved) in saved.drafts {
|
||||
for expected in [Layer::Oa, Layer::Sa] {
|
||||
if key(&facet, expected)
|
||||
.and_then(|id| project.layer_of(id))
|
||||
.is_some_and(|actual| actual != expected)
|
||||
{
|
||||
return Err("Recovered attachment address has a wrong-layer identity.".into());
|
||||
}
|
||||
}
|
||||
let (id, _) = actual_slot(&facet, layer)
|
||||
.ok_or("Recovered attachment has no destination record.")?;
|
||||
let path = path(project, &facet, layer)
|
||||
.ok_or("Recovered attachment record has a different kind.")?;
|
||||
let draft = Draft::from_recovery(project, saved)?;
|
||||
let original =
|
||||
Draft::from_project(project, id).ok_or("Recovered attachment is absent.")?;
|
||||
let mut protected = draft.value.clone();
|
||||
*protected
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Recovered attachment field is absent.")? =
|
||||
original.value.pointer(&path).expect("Closed path").clone();
|
||||
if draft.original != id
|
||||
|| protected != original.value
|
||||
|| draft.inputs.iter().any(|(key, input)| {
|
||||
key != &path
|
||||
&& original
|
||||
.inputs
|
||||
.get(key)
|
||||
.is_none_or(|old| old.raw != input.raw)
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"Recovered attachment draft changes identity, kind or an unrelated property."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if editor.drafts.insert(facet, draft).is_some() {
|
||||
return Err("Recovered attachment draft address is duplicated.".into());
|
||||
}
|
||||
}
|
||||
Ok(editor)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "attachments_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,795 @@
|
||||
//! Exact attachment adapters, addressed session composition, and recovery oracles.
|
||||
use super::*;
|
||||
struct Fixture {
|
||||
p: Project,
|
||||
owners: [Id; 2],
|
||||
records: [Id; 2],
|
||||
outside: [Id; 2],
|
||||
trace: Id,
|
||||
facet: FacetSelector,
|
||||
}
|
||||
fn fixture(child: bool, end: IncidentEnd) -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut records = vec![];
|
||||
let mut outside = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if child {
|
||||
if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
}
|
||||
} else {
|
||||
Kind::DataType
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Current owner", None).unwrap();
|
||||
let other = p.add_element(kind, layer, "Final owner", None).unwrap();
|
||||
let record = if child {
|
||||
p.add_element(kind, layer, "Child", Some(owner)).unwrap()
|
||||
} else {
|
||||
let peer = p.add_element(kind, layer, "Other endpoint", None).unwrap();
|
||||
let (a, b) = if end == IncidentEnd::Source {
|
||||
(owner, peer)
|
||||
} else {
|
||||
(peer, owner)
|
||||
};
|
||||
p.relate(RelationKind::DataAssociation, "Association", a, b)
|
||||
};
|
||||
owners.push(owner);
|
||||
records.push(record);
|
||||
outside.push(other);
|
||||
}
|
||||
for element in p.elements.values_mut() {
|
||||
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
}
|
||||
let trace = Id::new_v4();
|
||||
p.traces.insert(
|
||||
trace,
|
||||
Trace {
|
||||
id: trace,
|
||||
alias: "attachment_trace".into(),
|
||||
name: "Attachment rationale".into(),
|
||||
description: String::new(),
|
||||
oa: owners[0],
|
||||
sa: owners[1],
|
||||
kind: TraceKind::Manual,
|
||||
baseline: None,
|
||||
},
|
||||
);
|
||||
let keys = KeyPair {
|
||||
oa: Some(records[0]),
|
||||
sa: Some(records[1]),
|
||||
};
|
||||
let facet = if child {
|
||||
FacetSelector::Child(keys)
|
||||
} else {
|
||||
FacetSelector::Incident { keys, end }
|
||||
};
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
p,
|
||||
owners: [owners[0], owners[1]],
|
||||
records: [records[0], records[1]],
|
||||
outside: [outside[0], outside[1]],
|
||||
trace,
|
||||
facet,
|
||||
}
|
||||
}
|
||||
fn request(f: &Fixture, reverse: bool, decision: SemanticDecision) -> SemanticTransitionRequest {
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let direction = if reverse {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
};
|
||||
let mut transition = TransitionRequest::new(direction, vec![f.owners[source]]);
|
||||
transition.mappings.push(TransitionMapping {
|
||||
source: f.owners[source],
|
||||
target: Some(f.owners[target]),
|
||||
target_kind: None,
|
||||
});
|
||||
let mut r = SemanticTransitionRequest::new(transition);
|
||||
r.traces.insert(f.owners[source], f.trace);
|
||||
r.references
|
||||
.extend([(f.owners[0], f.owners[1]), (f.records[0], f.records[1])]);
|
||||
r.reviews.push(SemanticReviewRequest {
|
||||
source: f.owners[source],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: f.facet.clone(),
|
||||
decision,
|
||||
}],
|
||||
reset: vec![],
|
||||
});
|
||||
r
|
||||
}
|
||||
fn typed(f: &Fixture, record: Value) -> SemanticRecord {
|
||||
if matches!(f.facet, FacetSelector::Child(_)) {
|
||||
SemanticRecord::Element(serde_json::from_value(record).unwrap())
|
||||
} else {
|
||||
SemanticRecord::Relation(serde_json::from_value(record).unwrap())
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn parent_and_each_endpoint_candidate_preserve_other_fields_and_match_core_both_directions() {
|
||||
for child in [true, false] {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
if child && end == IncidentEnd::Target {
|
||||
continue;
|
||||
}
|
||||
for reverse in [false, true] {
|
||||
let f = fixture(child, end);
|
||||
let r = request(&f, reverse, SemanticDecision::Candidate);
|
||||
let direction = r.transition.direction;
|
||||
let layer = direction.target_layer();
|
||||
let target = usize::from(!reverse);
|
||||
let mut editor = Editor::default();
|
||||
editor.ensure(&f.p, &f.facet, layer).unwrap();
|
||||
let path = path(&f.p, &f.facet, layer).unwrap();
|
||||
editor
|
||||
.drafts
|
||||
.get_mut(&f.facet)
|
||||
.unwrap()
|
||||
.shape_scoped(&path, serde_json::json!(f.outside[target]))
|
||||
.unwrap();
|
||||
let proposal = editor
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
direction,
|
||||
&f.facet,
|
||||
SemanticDecision::Candidate,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(proposal.slot, actual_slot(&f.facet, layer).unwrap());
|
||||
assert_eq!(proposal.path, path);
|
||||
let proposed = typed(&f, proposal.record);
|
||||
let mut r = r;
|
||||
r.candidates.push(proposed.clone());
|
||||
let plan = plan_semantic_transition(&f.p, r).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let result = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(semantic_record(&result, f.records[target]), Some(proposed));
|
||||
assert_eq!(
|
||||
semantic_record(&result, f.records[1 - target]),
|
||||
semantic_record(&f.p, f.records[1 - target])
|
||||
);
|
||||
assert_eq!(
|
||||
result.traces.len(),
|
||||
1,
|
||||
"The attached record needs no invented trace"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn source_only_absence_and_explicit_absent_endpoint_are_different() {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture(false, end);
|
||||
let r = request(&f, reverse, SemanticDecision::TakeSource);
|
||||
let direction = r.transition.direction;
|
||||
let source = usize::from(reverse);
|
||||
let target = 1 - source;
|
||||
let relation = f.p.relations.get_mut(&f.records[source]).unwrap();
|
||||
let (a, b) = relation.body.endpoints();
|
||||
relation.body.set_endpoints(
|
||||
if end == IncidentEnd::Source {
|
||||
f.outside[source]
|
||||
} else {
|
||||
a
|
||||
},
|
||||
if end == IncidentEnd::Target {
|
||||
f.outside[source]
|
||||
} else {
|
||||
b
|
||||
},
|
||||
);
|
||||
let editor = Editor::default();
|
||||
assert!(
|
||||
editor
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
direction,
|
||||
&f.facet,
|
||||
SemanticDecision::TakeSource,
|
||||
false
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("explicit replacement")
|
||||
);
|
||||
let FacetSelector::Incident { keys, .. } = &mut f.facet else {
|
||||
unreachable!()
|
||||
};
|
||||
if reverse {
|
||||
keys.sa = None;
|
||||
} else {
|
||||
keys.oa = None;
|
||||
}
|
||||
let proposal = editor
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
direction,
|
||||
&f.facet,
|
||||
SemanticDecision::TakeSource,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
typed(&f, proposal.record),
|
||||
semantic_record(&f.p, f.records[target]).unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attachment_pair_drafts_survive_family_switch_and_restore_without_mutating_model() {
|
||||
let f = fixture(false, IncidentEnd::Source);
|
||||
let mut editor = Editor::default();
|
||||
editor.select_pair(&f.p, &f.facet).unwrap();
|
||||
editor
|
||||
.update(&f.p, &f.p.traces[&f.trace], Event::Family(Family::Target))
|
||||
.unwrap();
|
||||
editor
|
||||
.update(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
Event::Pick(Layer::Sa, Some(view::choice(&f.p, f.records[1]))),
|
||||
)
|
||||
.unwrap();
|
||||
editor
|
||||
.update(&f.p, &f.p.traces[&f.trace], Event::Family(Family::Source))
|
||||
.unwrap();
|
||||
let staged = editor
|
||||
.update(&f.p, &f.p.traces[&f.trace], Event::Stage)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(staged, f.facet);
|
||||
let saved = serde_json::to_value(editor.recovery_snapshot()).unwrap();
|
||||
let restored = Editor::from_recovery(
|
||||
&f.p,
|
||||
Layer::Sa,
|
||||
serde_json::from_value(saved.clone()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored.recovery_snapshot()).unwrap(),
|
||||
saved
|
||||
);
|
||||
assert_eq!(restored.selection(Layer::Oa).unwrap().id, f.records[0]);
|
||||
assert!(
|
||||
editor
|
||||
.choices(&f.p, &f.p.traces[&f.trace], Layer::Sa)
|
||||
.iter()
|
||||
.any(|choice| choice.id == f.records[1])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn raw_rejected_attachment_reference_survives_recovery_and_wrong_scope_is_rejected() {
|
||||
let f = fixture(true, IncidentEnd::Source);
|
||||
let mut editor = Editor::default();
|
||||
editor.ensure(&f.p, &f.facet, Layer::Sa).unwrap();
|
||||
editor
|
||||
.drafts
|
||||
.get_mut(&f.facet)
|
||||
.unwrap()
|
||||
.input("/parent", "-".into());
|
||||
let saved = serde_json::to_value(editor.recovery_snapshot()).unwrap();
|
||||
let mut restored = Editor::from_recovery(
|
||||
&f.p,
|
||||
Layer::Sa,
|
||||
serde_json::from_value(saved.clone()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(restored.drafts[&f.facet].inputs["/parent"].raw, "-");
|
||||
let proposed = restored
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
TransitionDirection::OaToSa,
|
||||
&f.facet,
|
||||
SemanticDecision::Candidate,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(serde_json::from_value::<Element>(proposed.record).is_err());
|
||||
restored
|
||||
.drafts
|
||||
.get_mut(&f.facet)
|
||||
.unwrap()
|
||||
.input("/parent", f.outside[1].to_string());
|
||||
let repaired = restored
|
||||
.propose(
|
||||
&f.p,
|
||||
&f.p.traces[&f.trace],
|
||||
TransitionDirection::OaToSa,
|
||||
&f.facet,
|
||||
SemanticDecision::Candidate,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Element>(repaired.record)
|
||||
.unwrap()
|
||||
.parent,
|
||||
Some(f.outside[1])
|
||||
);
|
||||
let mut duplicate = saved.clone();
|
||||
let row = duplicate["drafts"][0].clone();
|
||||
duplicate["drafts"].as_array_mut().unwrap().push(row);
|
||||
assert!(
|
||||
Editor::from_recovery(&f.p, Layer::Sa, serde_json::from_value(duplicate).unwrap()).is_err()
|
||||
);
|
||||
let mut changed = saved;
|
||||
changed["drafts"][0][1]["value"]["name"] = serde_json::json!("Unrelated changed name");
|
||||
assert!(
|
||||
Editor::from_recovery(&f.p, Layer::Sa, serde_json::from_value(changed).unwrap()).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
let event = state.address_event(event);
|
||||
state.update(p, event);
|
||||
}
|
||||
fn session(f: &Fixture, reverse: bool) -> State {
|
||||
let mut state = State::new(&f.p, f.trace).unwrap();
|
||||
if reverse {
|
||||
state.update(
|
||||
&f.p,
|
||||
super::super::Event::Direction(TransitionDirection::SaToOa),
|
||||
);
|
||||
}
|
||||
send(&mut state, &f.p, RowEvent::EditPair(f.facet.clone()));
|
||||
send(&mut state, &f.p, RowEvent::Attachment(Event::Stage));
|
||||
assert_eq!(state.references.get(&f.records[0]), Some(&f.records[1]));
|
||||
state
|
||||
}
|
||||
fn candidate(state: &mut State, p: &Project, facet: &FacetSelector, value: Id) {
|
||||
let path = path(p, facet, state.direction.target_layer()).unwrap();
|
||||
send(state, p, RowEvent::Select(facet.clone()));
|
||||
send(
|
||||
state,
|
||||
p,
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(state, p, RowEvent::Shape(path, serde_json::json!(value)));
|
||||
}
|
||||
fn errors(state: &State) -> String {
|
||||
format!(
|
||||
"{:?} / {:?}",
|
||||
state.error,
|
||||
state.plan.as_ref().map(|p| &p.conflicts)
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn addressed_attachment_candidates_equal_direct_core_oracle_both_directions() {
|
||||
for child in [true, false] {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
if child && end == IncidentEnd::Target {
|
||||
continue;
|
||||
}
|
||||
for reverse in [false, true] {
|
||||
let f = fixture(child, end);
|
||||
let target = usize::from(!reverse);
|
||||
let mut state = session(&f, reverse);
|
||||
candidate(&mut state, &f.p, &f.facet, f.outside[target]);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let mut direct = request(&f, reverse, SemanticDecision::Candidate);
|
||||
let mut record = Draft::from_project(&f.p, f.records[target]).unwrap().value;
|
||||
*record
|
||||
.pointer_mut(&path(&f.p, &f.facet, state.direction.target_layer()).unwrap())
|
||||
.unwrap() = serde_json::json!(f.outside[target]);
|
||||
direct.candidates.push(typed(&f, record));
|
||||
let plan = plan_semantic_transition(&f.p, direct).unwrap();
|
||||
let expected = prepare_semantic_transition(&f.p, &plan).unwrap().project;
|
||||
assert_eq!(state.proposed.as_ref(), Some(&expected));
|
||||
assert!(state.command(&f.p).is_ok());
|
||||
assert_eq!(
|
||||
f.p.traces[&f.trace].baseline, None,
|
||||
"Preview cannot write source history"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn attachment_candidate_and_direct_record_keep_conflict_on_actual_slot_without_losing_drafts() {
|
||||
for child in [true, false] {
|
||||
for end in [IncidentEnd::Source, IncidentEnd::Target] {
|
||||
if child && end == IncidentEnd::Target {
|
||||
continue;
|
||||
}
|
||||
for reverse in [false, true] {
|
||||
let mut f = fixture(child, end);
|
||||
let trace = super::super::tests::add_trace(
|
||||
&mut f.p,
|
||||
f.records[0],
|
||||
f.records[1],
|
||||
"Direct record rationale",
|
||||
);
|
||||
let mut state = session(&f, reverse);
|
||||
candidate(&mut state, &f.p, &f.facet, f.outside[usize::from(!reverse)]);
|
||||
let authored = state.active_address.unwrap();
|
||||
state.update(
|
||||
&f.p,
|
||||
super::super::Event::ChooseTrace(view::choice(&f.p, trace)),
|
||||
);
|
||||
state.update(&f.p, super::super::Event::OpenTrace);
|
||||
let (_, slot) = actual_slot(&f.facet, state.direction.target_layer()).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Decide(slot, Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(
|
||||
errors(&state).contains("Independent row proposals disagree"),
|
||||
"{}",
|
||||
errors(&state)
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.row(authored)
|
||||
.unwrap()
|
||||
.attachments
|
||||
.drafts
|
||||
.contains_key(&f.facet)
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(!restored.can_apply());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn two_incident_endpoint_candidates_form_one_complete_record_and_recover_invalid_excluded_draft() {
|
||||
let mut f = fixture(false, IncidentEnd::Source);
|
||||
// Both ends are incident to the reviewed owner, so source and target are two
|
||||
// independent attachment addresses on the same actual relationship.
|
||||
for i in 0..2 {
|
||||
f.p.relations
|
||||
.get_mut(&f.records[i])
|
||||
.unwrap()
|
||||
.body
|
||||
.set_endpoints(f.owners[i], f.owners[i]);
|
||||
}
|
||||
let other = FacetSelector::Incident {
|
||||
keys: KeyPair {
|
||||
oa: Some(f.records[0]),
|
||||
sa: Some(f.records[1]),
|
||||
},
|
||||
end: IncidentEnd::Target,
|
||||
};
|
||||
let mut state = session(&f, false);
|
||||
candidate(&mut state, &f.p, &f.facet, f.outside[1]);
|
||||
send(&mut state, &f.p, RowEvent::EditPair(other.clone()));
|
||||
send(&mut state, &f.p, RowEvent::Attachment(Event::Stage));
|
||||
candidate(&mut state, &f.p, &other, f.outside[1]);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().relations[&f.records[1]]
|
||||
.body
|
||||
.endpoints(),
|
||||
(f.outside[1], f.outside[1])
|
||||
);
|
||||
let path = path(&f.p, &other, Layer::Sa).unwrap();
|
||||
send(&mut state, &f.p, RowEvent::Input(path.clone(), "-".into()));
|
||||
assert!(!state.can_apply());
|
||||
let address = state.active_address.unwrap();
|
||||
state.update(&f.p, super::super::Event::Include(address, false));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(!restored.included.contains(&address));
|
||||
assert_eq!(restored.attachments.drafts[&other].inputs[&path].raw, "-");
|
||||
restored.update(&f.p, super::super::Event::Include(address, true));
|
||||
assert!(!restored.can_apply());
|
||||
send(
|
||||
&mut restored,
|
||||
&f.p,
|
||||
RowEvent::Input(path, f.outside[1].to_string()),
|
||||
);
|
||||
assert!(restored.can_apply(), "{}", errors(&restored));
|
||||
}
|
||||
#[test]
|
||||
fn attachment_apply_is_one_exact_source_and_history_undo_redo() {
|
||||
use crate::app::App;
|
||||
let f = fixture(true, IncidentEnd::Source);
|
||||
let mut app = App::with_project(f.p.clone());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Child allocation rationale stays exact\r\n");
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
super::super::tests::start(&mut app, f.trace);
|
||||
super::super::tests::send(&mut app, RowEvent::EditPair(f.facet.clone()));
|
||||
super::super::tests::send(&mut app, RowEvent::Attachment(Event::Stage));
|
||||
super::super::tests::decide(&mut app, f.facet.clone(), SemanticDecision::Candidate);
|
||||
super::super::tests::send(
|
||||
&mut app,
|
||||
RowEvent::Shape("/parent".into(), serde_json::json!(f.outside[1])),
|
||||
);
|
||||
let state = super::super::tests::state(&app);
|
||||
assert!(state.can_apply(), "{}", errors(state));
|
||||
let expected = state.proposed.clone().unwrap();
|
||||
super::super::tests::apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.snapshot();
|
||||
assert!(
|
||||
after.bundle.files[&after.bundle.entry]
|
||||
.starts_with("// Child allocation rationale stays exact\r\n")
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_attachment_payloads_migrate_v1_and_v2_and_source5_upgrade_requires_exact_legacy_base() {
|
||||
let f = fixture(true, IncidentEnd::Source);
|
||||
let mut state = State::new(&f.p, f.trace).unwrap();
|
||||
let facet = FacetSelector::Header(HeaderFacet::Name);
|
||||
send(&mut state, &f.p, RowEvent::Select(facet.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.p,
|
||||
RowEvent::Input("/name".into(), "Retained exact name".into()),
|
||||
);
|
||||
for mut value in [
|
||||
state.active.recovery_snapshot().unwrap(),
|
||||
state.recovery_snapshot().unwrap(),
|
||||
] {
|
||||
if value["version"] == 1 {
|
||||
value.as_object_mut().unwrap().remove("attachments");
|
||||
} else {
|
||||
for row in value["rows"].as_array_mut().unwrap() {
|
||||
row["draft"].as_object_mut().unwrap().remove("attachments");
|
||||
}
|
||||
}
|
||||
let restored = State::from_recovery(&f.p, value.clone()).unwrap();
|
||||
assert!(restored.attachments.drafts.is_empty());
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Retained exact name"
|
||||
);
|
||||
value["base_fingerprint"] = serde_json::json!("verified legacy source-5 fingerprint");
|
||||
assert!(State::from_recovery(&f.p, value.clone()).is_err());
|
||||
assert!(
|
||||
State::from_recovery_after_source5_upgrade(&f.p, "wrong base", value.clone()).is_err()
|
||||
);
|
||||
let upgraded = State::from_recovery_after_source5_upgrade(
|
||||
&f.p,
|
||||
"verified legacy source-5 fingerprint",
|
||||
value.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(upgraded.base_fingerprint, project_fingerprint(&f.p));
|
||||
assert_eq!(upgraded.decisions, restored.decisions);
|
||||
assert_eq!(
|
||||
upgraded.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Retained exact name"
|
||||
);
|
||||
assert!(upgraded.accepted_impacts.is_empty());
|
||||
value["base_identity"] = serde_json::json!(Id::new_v4());
|
||||
assert!(
|
||||
State::from_recovery_after_source5_upgrade(
|
||||
&f.p,
|
||||
"verified legacy source-5 fingerprint",
|
||||
value
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_incident_source_requires_explicit_matching_port_review_in_one_atomic_candidate() {
|
||||
let mut p = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut replacements = vec![];
|
||||
let mut exchanges = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let kind = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let owner = p.add_element(kind, layer, "Sender", None).unwrap();
|
||||
let receiver = p.add_element(kind, layer, "Receiver", None).unwrap();
|
||||
let replacement = p
|
||||
.add_element(kind, layer, "Reviewed replacement sender", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Payload exchange",
|
||||
owner,
|
||||
receiver,
|
||||
);
|
||||
owners.push(owner);
|
||||
replacements.push(replacement);
|
||||
exchanges.push(exchange);
|
||||
}
|
||||
let port = Id::new_v4();
|
||||
p.elements.insert(
|
||||
port,
|
||||
make_port(
|
||||
&p,
|
||||
port,
|
||||
replacements[1],
|
||||
"Reviewed replacement output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let owner_trace =
|
||||
super::super::tests::add_trace(&mut p, owners[0], owners[1], "Sender ownership rationale");
|
||||
let exchange_trace = super::super::tests::add_trace(
|
||||
&mut p,
|
||||
exchanges[0],
|
||||
exchanges[1],
|
||||
"Explicit exchange port rationale",
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let before = p.clone();
|
||||
let facet = FacetSelector::Incident {
|
||||
keys: KeyPair {
|
||||
oa: Some(exchanges[0]),
|
||||
sa: Some(exchanges[1]),
|
||||
},
|
||||
end: IncidentEnd::Source,
|
||||
};
|
||||
let mut state = State::new(&p, owner_trace).unwrap();
|
||||
send(&mut state, &p, RowEvent::EditPair(facet.clone()));
|
||||
send(&mut state, &p, RowEvent::Attachment(Event::Stage));
|
||||
candidate(&mut state, &p, &facet, replacements[1]);
|
||||
assert!(!state.can_apply());
|
||||
assert!(
|
||||
errors(&state).contains("functional_port"),
|
||||
"{}",
|
||||
errors(&state)
|
||||
);
|
||||
assert!(
|
||||
state.plan.as_ref().unwrap().impacts.is_empty(),
|
||||
"Matching ports are explicit review choices, never invented impacts"
|
||||
);
|
||||
|
||||
state.update(
|
||||
&p,
|
||||
super::super::Event::ChooseTrace(view::choice(&p, exchange_trace)),
|
||||
);
|
||||
state.update(&p, super::super::Event::OpenTrace);
|
||||
// The direct Source agrees with the owner's Incident Source. It also gives
|
||||
// the direct row's port picker the explicitly authored replacement owner.
|
||||
for (selector, value) in [
|
||||
(RelationFacet::Source, replacements[1]),
|
||||
(RelationFacet::SourcePort, port),
|
||||
] {
|
||||
let selector = FacetSelector::Relation(selector);
|
||||
send(&mut state, &p, RowEvent::Select(selector.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(selector.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let field = scalar::binding(&p, exchanges[1], &selector).unwrap();
|
||||
if selector == FacetSelector::Relation(RelationFacet::SourcePort) {
|
||||
let draft = state.candidate_draft(&selector).unwrap();
|
||||
assert!(
|
||||
crate::forms::reference_choices(draft, &p, &field, Id::nil())
|
||||
.iter()
|
||||
.any(|choice| choice.id == port),
|
||||
"The actual scoped picker must offer the matching explicitly owned port"
|
||||
);
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Shape(field, serde_json::json!(value)),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(state.plan.as_ref().unwrap().impacts.is_empty());
|
||||
let request = &state.plan.as_ref().unwrap().request;
|
||||
assert_eq!(
|
||||
request.candidates.len(),
|
||||
1,
|
||||
"Two owner records must not become two conflicting partial relationship candidates"
|
||||
);
|
||||
assert_eq!(request.reviews.len(), 2);
|
||||
assert!(
|
||||
request
|
||||
.reviews
|
||||
.iter()
|
||||
.find(|review| review.source == exchanges[0])
|
||||
.unwrap()
|
||||
.resolutions
|
||||
.iter()
|
||||
.any(|resolution| resolution.facet
|
||||
== FacetSelector::Relation(RelationFacet::SourcePort)
|
||||
&& resolution.decision == SemanticDecision::Candidate)
|
||||
);
|
||||
|
||||
let mut transition =
|
||||
TransitionRequest::new(TransitionDirection::OaToSa, vec![owners[0], exchanges[0]]);
|
||||
transition.mappings = vec![
|
||||
TransitionMapping {
|
||||
source: owners[0],
|
||||
target: Some(owners[1]),
|
||||
target_kind: None,
|
||||
},
|
||||
TransitionMapping {
|
||||
source: exchanges[0],
|
||||
target: Some(exchanges[1]),
|
||||
target_kind: None,
|
||||
},
|
||||
];
|
||||
let mut direct = SemanticTransitionRequest::new(transition);
|
||||
direct.references = BTreeMap::from([(owners[0], owners[1]), (exchanges[0], exchanges[1])]);
|
||||
direct.traces = BTreeMap::from([(owners[0], owner_trace), (exchanges[0], exchange_trace)]);
|
||||
direct.reviews = vec![
|
||||
SemanticReviewRequest {
|
||||
source: owners[0],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet,
|
||||
decision: SemanticDecision::Candidate,
|
||||
}],
|
||||
reset: vec![],
|
||||
},
|
||||
SemanticReviewRequest {
|
||||
source: exchanges[0],
|
||||
resolutions: [RelationFacet::Source, RelationFacet::SourcePort]
|
||||
.into_iter()
|
||||
.map(|facet| FacetResolution {
|
||||
facet: FacetSelector::Relation(facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
})
|
||||
.collect(),
|
||||
reset: vec![],
|
||||
},
|
||||
];
|
||||
let mut relation = p.relations[&exchanges[1]].clone();
|
||||
let (_, receiver) = relation.body.endpoints();
|
||||
relation.body.set_endpoints(replacements[1], receiver);
|
||||
if let RelationBody::FunctionalExchange { source_port, .. } = &mut relation.body {
|
||||
*source_port = port;
|
||||
} else {
|
||||
unreachable!();
|
||||
}
|
||||
direct.candidates.push(SemanticRecord::Relation(relation));
|
||||
let plan = plan_semantic_transition(&p, direct).unwrap();
|
||||
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
|
||||
let expected = prepare_semantic_transition(&p, &plan).unwrap().project;
|
||||
assert_eq!(state.proposed.as_ref(), Some(&expected));
|
||||
assert!(state.command(&p).is_ok());
|
||||
assert_eq!(
|
||||
p, before,
|
||||
"Combined preview never mutates accepted ports or model history"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Explicit attachment pair and final destination field controls.
|
||||
use super::{
|
||||
attachments::{Editor, Event, Family},
|
||||
*,
|
||||
};
|
||||
use iced::{
|
||||
Element as View, Fill,
|
||||
widget::{button, column, pick_list, row, text, text_input},
|
||||
};
|
||||
|
||||
pub(super) fn pairs<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
trace: &'a Trace,
|
||||
) -> View<'a, Event> {
|
||||
let mut content = column![
|
||||
text("Existing children and relationship endpoints").size(16),
|
||||
text("Choose explicit Operational and System identities. A child's final parent or a relationship's final endpoint is reviewed independently; opening another trace keeps this draft.").size(12),
|
||||
pick_list([Family::Child, Family::Source, Family::Target], Some(editor.family), Event::Family).text_size(12).padding(7).width(Fill),
|
||||
text_input("Filter attachment identities by name or alias", &editor.search).on_input(Event::Search).size(12).padding(7),
|
||||
].spacing(9);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let label = if layer == Layer::Oa {
|
||||
"Operational"
|
||||
} else {
|
||||
"System"
|
||||
};
|
||||
let owner = if layer == Layer::Oa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
content = content.push(text(format!("{label} owner: {}", project.named(owner))).size(12));
|
||||
content = content.push(
|
||||
row![
|
||||
pick_list(
|
||||
editor.choices(project, trace, layer),
|
||||
editor.selection(layer).cloned(),
|
||||
move |choice| Event::Pick(layer, Some(choice))
|
||||
)
|
||||
.placeholder(format!("Choose an existing {label} identity"))
|
||||
.text_size(12)
|
||||
.padding(7)
|
||||
.width(Fill),
|
||||
button(text("No identity on this side").size(11))
|
||||
.style(crate::style::nav)
|
||||
.padding(7)
|
||||
.on_press(Event::Pick(layer, None)),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
content = content.push(
|
||||
button(text("Use exact attachment pair").size(12))
|
||||
.padding(8)
|
||||
.style(crate::style::nav)
|
||||
.on_press(Event::Stage),
|
||||
);
|
||||
content.push(text("Taking an absent relationship endpoint requires an explicit final replacement. New child and relationship creation uses a separate explicit transition mapping.").size(11)).padding(iced::Padding::default().right(18)).into()
|
||||
}
|
||||
|
||||
pub(super) fn candidate<'a>(
|
||||
editor: &'a Editor,
|
||||
project: &'a Project,
|
||||
facet: &'a FacetSelector,
|
||||
layer: Layer,
|
||||
) -> Option<View<'a, Message>> {
|
||||
let draft = editor.drafts.get(facet)?;
|
||||
let (id, _) = attachments::actual_slot(facet, layer)?;
|
||||
let path = attachments::path(project, facet, layer)?;
|
||||
Some(
|
||||
column![
|
||||
text(format!("Destination record: {}", project.named(id))).size(12),
|
||||
crate::forms::scoped(draft, project, &path, attachments::label(facet)),
|
||||
]
|
||||
.spacing(7)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
//! Session orchestration. Row focus never changes the identity of authored drafts.
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
ops::{Deref, DerefMut},
|
||||
sync::Arc,
|
||||
};
|
||||
pub(super) const MAX_REVIEW_ROWS: usize = 256;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RowAddress {
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub trace: Id,
|
||||
}
|
||||
impl RowAddress {
|
||||
pub(super) fn from_trace(trace: &Trace, direction: TransitionDirection) -> Self {
|
||||
let (source, target) = match direction {
|
||||
TransitionDirection::OaToSa => (trace.oa, trace.sa),
|
||||
TransitionDirection::SaToOa => (trace.sa, trace.oa),
|
||||
};
|
||||
Self {
|
||||
source,
|
||||
target,
|
||||
trace: trace.id,
|
||||
}
|
||||
}
|
||||
pub(super) fn direction(self, project: &Project) -> Result<TransitionDirection, String> {
|
||||
let trace = project
|
||||
.traces
|
||||
.get(&self.trace)
|
||||
.ok_or("Review trace no longer exists.")?;
|
||||
if self.source == trace.oa && self.target == trace.sa {
|
||||
Ok(TransitionDirection::OaToSa)
|
||||
} else if self.source == trace.sa && self.target == trace.oa {
|
||||
Ok(TransitionDirection::SaToOa)
|
||||
} else {
|
||||
Err("Review row and its exact trace correspondence disagree.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
AcceptImpact(Box<SemanticImpact>, bool),
|
||||
ChooseTrace(Choice),
|
||||
OpenTrace,
|
||||
Focus(RowAddress),
|
||||
Include(RowAddress, bool),
|
||||
Direction(TransitionDirection),
|
||||
Row {
|
||||
address: Option<RowAddress>,
|
||||
facet: Option<FacetSelector>,
|
||||
event: RowEvent,
|
||||
},
|
||||
// Intermediate row-view message, always addressed by State::view before reaching App.
|
||||
Editor(RowEvent),
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl From<RowEvent> for Event {
|
||||
fn from(event: RowEvent) -> Self {
|
||||
Self::Editor(event)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct State {
|
||||
pub(super) base: Arc<Project>,
|
||||
pub(super) base_identity: Id,
|
||||
pub(super) base_fingerprint: String,
|
||||
pub direction: TransitionDirection,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
pub plan: Option<SemanticTransitionPlan>,
|
||||
pub proposed: Option<Project>,
|
||||
pub error: Option<String>,
|
||||
pub(super) accepted_impacts: Vec<SemanticImpact>,
|
||||
pub(super) active: ReviewRow,
|
||||
pub(super) active_address: Option<RowAddress>,
|
||||
pub(super) bank: BTreeMap<RowAddress, ReviewRow>,
|
||||
pub(super) order: Vec<RowAddress>,
|
||||
pub(super) included: BTreeSet<RowAddress>,
|
||||
pub(super) add_trace: Option<Choice>,
|
||||
pub(super) notice: Option<String>,
|
||||
}
|
||||
// The active editor remains accessible to existing local property tests/helpers.
|
||||
// Session planning/error/reference fields deliberately take precedence over row context.
|
||||
impl Deref for State {
|
||||
type Target = ReviewRow;
|
||||
fn deref(&self) -> &ReviewRow {
|
||||
&self.active
|
||||
}
|
||||
}
|
||||
impl DerefMut for State {
|
||||
fn deref_mut(&mut self) -> &mut ReviewRow {
|
||||
&mut self.active
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
||||
let active = ReviewRow::new(project, selected)?;
|
||||
let direction = active.direction;
|
||||
let active_address = active
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.map(|trace| RowAddress::from_trace(trace, direction));
|
||||
let mut state = Self {
|
||||
base: Arc::new(project.clone()),
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
direction,
|
||||
references: BTreeMap::new(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
accepted_impacts: vec![],
|
||||
active,
|
||||
active_address,
|
||||
bank: BTreeMap::new(),
|
||||
order: active_address.into_iter().collect(),
|
||||
included: active_address.into_iter().collect(),
|
||||
add_trace: None,
|
||||
notice: None,
|
||||
};
|
||||
state.refresh(project);
|
||||
Ok(state)
|
||||
}
|
||||
pub fn target(&self, _project: &Project) -> Option<Id> {
|
||||
self.active_address.map(|address| address.target)
|
||||
}
|
||||
pub(super) fn row(&self, address: RowAddress) -> Option<&ReviewRow> {
|
||||
if self.active_address == Some(address) {
|
||||
Some(&self.active)
|
||||
} else {
|
||||
self.bank.get(&address)
|
||||
}
|
||||
}
|
||||
fn row_mut(&mut self, address: Option<RowAddress>) -> Option<&mut ReviewRow> {
|
||||
if self.active_address == address {
|
||||
Some(&mut self.active)
|
||||
} else {
|
||||
address.and_then(|address| self.bank.get_mut(&address))
|
||||
}
|
||||
}
|
||||
pub(super) fn included_addresses(&self) -> Vec<RowAddress> {
|
||||
self.included
|
||||
.iter()
|
||||
.filter(|address| address.direction(&self.base).ok() == Some(self.direction))
|
||||
.copied()
|
||||
.collect()
|
||||
}
|
||||
pub(super) fn frozen(&self, project: &Project) -> Result<(), String> {
|
||||
if project.id != self.base_identity || project_fingerprint(project) != self.base_fingerprint
|
||||
{
|
||||
Err("This review is stale: the accepted model changed after this review began. Drafts are retained; reopen a review against the new model before applying.".into())
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub fn address_event(&self, event: RowEvent) -> Event {
|
||||
if let RowEvent::AcceptImpact(index, accepted) = &event
|
||||
&& let Some(impact) = self.plan.as_ref().and_then(|plan| plan.impacts.get(*index))
|
||||
{
|
||||
return Event::AcceptImpact(Box::new(impact.clone()), *accepted);
|
||||
}
|
||||
Event::Row {
|
||||
address: self.active_address,
|
||||
facet: self.active.selected.clone(),
|
||||
event,
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(super) fn stage_row_references(&mut self, facet: &FacetSelector) -> Result<(), String> {
|
||||
self.active.stage_row_references(facet)?;
|
||||
self.references.extend(self.active.references.clone());
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn canonical_references(&self) -> Result<BTreeMap<Id, Id>, String> {
|
||||
let mut references = self.references.clone();
|
||||
let mut sources = BTreeSet::new();
|
||||
for address in self.included_addresses() {
|
||||
if !sources.insert(address.source) {
|
||||
return Err("Only one trace row per source can be included. Exclude its alternative history explicitly.".into());
|
||||
}
|
||||
let trace = &self.base.traces[&address.trace];
|
||||
if references
|
||||
.insert(trace.oa, trace.sa)
|
||||
.is_some_and(|old| old != trace.sa)
|
||||
{
|
||||
return Err(format!(
|
||||
"Transition mapping disagrees with frozen correspondence: {}. Reverse convergence of multiple System sources on one Operational target is not representable by the core's canonical reference map. Exclude a row or choose a distinct target; all drafts are retained.",
|
||||
self.base.named(trace.oa)
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(references)
|
||||
}
|
||||
fn open(&mut self, trace_id: Id, replace_active: bool) -> Result<(), String> {
|
||||
let trace = self
|
||||
.base
|
||||
.traces
|
||||
.get(&trace_id)
|
||||
.ok_or("Choose an existing trace.")?;
|
||||
let address = RowAddress::from_trace(trace, self.direction);
|
||||
if self.active_address == Some(address) {
|
||||
return Ok(());
|
||||
}
|
||||
let old = self.active_address;
|
||||
let transfer = replace_active && old.is_some_and(|old| self.included.contains(&old));
|
||||
let existed = self.bank.contains_key(&address);
|
||||
if !existed && self.order.len() >= MAX_REVIEW_ROWS {
|
||||
return Err("Semantic review exceeds the supported 256 retained rows.".into());
|
||||
}
|
||||
let mut next = if let Some(row) = self.bank.remove(&address) {
|
||||
row
|
||||
} else {
|
||||
let mut row = ReviewRow::new(&self.base, trace_id)?;
|
||||
row.direction = self.direction;
|
||||
row.load_trace(&self.base);
|
||||
row
|
||||
};
|
||||
// References are a derived editor context; the session retains authored pairs.
|
||||
next.references = self.references.clone();
|
||||
let previous = std::mem::replace(&mut self.active, next);
|
||||
if let Some(old) = old {
|
||||
self.bank.insert(old, previous);
|
||||
}
|
||||
self.active_address = Some(address);
|
||||
if !self.order.contains(&address) {
|
||||
self.order.push(address);
|
||||
}
|
||||
if transfer && let Some(old) = old {
|
||||
self.included.remove(&old);
|
||||
}
|
||||
let occupied = self
|
||||
.included_addresses()
|
||||
.iter()
|
||||
.any(|other| other.source == address.source && *other != address);
|
||||
if !occupied && (transfer || !existed) {
|
||||
self.included.insert(address);
|
||||
}
|
||||
if occupied {
|
||||
self.notice = Some("This source already has an included history. The alternative draft is open but excluded; exclude the other row before including this one.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn change_direction(
|
||||
&mut self,
|
||||
direction: TransitionDirection,
|
||||
trace: Option<Id>,
|
||||
) -> Result<(), String> {
|
||||
let previous = self.direction;
|
||||
self.direction = direction;
|
||||
if let Some(trace) = trace {
|
||||
if let Err(error) = self.open(trace, false) {
|
||||
self.direction = previous;
|
||||
return Err(error);
|
||||
}
|
||||
} else {
|
||||
self.active.direction = direction;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn focus(&mut self, address: RowAddress) -> Result<(), String> {
|
||||
if self.active_address == Some(address) {
|
||||
return Ok(());
|
||||
}
|
||||
let next = self
|
||||
.bank
|
||||
.remove(&address)
|
||||
.ok_or("That review draft is no longer available.")?;
|
||||
let previous = std::mem::replace(&mut self.active, next);
|
||||
if let Some(old) = self.active_address {
|
||||
self.bank.insert(old, previous);
|
||||
}
|
||||
self.active_address = Some(address);
|
||||
self.direction = address.direction(&self.base)?;
|
||||
Ok(())
|
||||
}
|
||||
fn include(&mut self, address: RowAddress, include: bool) -> Result<(), String> {
|
||||
if self.row(address).is_none() {
|
||||
return Err("That review row is unavailable.".into());
|
||||
}
|
||||
if include {
|
||||
let direction = address.direction(&self.base)?;
|
||||
if self.included.iter().any(|other| {
|
||||
other != &address
|
||||
&& other.source == address.source
|
||||
&& other.direction(&self.base).ok() == Some(direction)
|
||||
}) {
|
||||
return Err("Only one included trace per source is allowed. Exclude the alternative history first; both drafts are retained.".into());
|
||||
}
|
||||
self.included.insert(address);
|
||||
} else {
|
||||
self.included.remove(&address);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn update(&mut self, project: &Project, event: impl Into<Event>) {
|
||||
let event = event.into();
|
||||
#[cfg(test)]
|
||||
let event = match event {
|
||||
Event::Editor(event) => self.address_event(event),
|
||||
other => other,
|
||||
};
|
||||
if let Err(error) = self.frozen(project) {
|
||||
// Withdrawing a pending reset is authored intent only. Retain it while
|
||||
// keeping the original accepted base and disabling every stale plan.
|
||||
if let Event::Row {
|
||||
address,
|
||||
event: RowEvent::Reset(facet, false),
|
||||
..
|
||||
} = &event
|
||||
&& let Some(row) = self.row_mut(*address)
|
||||
&& row.resets.remove(facet)
|
||||
{
|
||||
self.accepted_impacts.clear();
|
||||
}
|
||||
self.error = Some(error.clone());
|
||||
self.active.error = Some(error);
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
return;
|
||||
}
|
||||
let base = Arc::clone(&self.base);
|
||||
let browser_only = matches!(
|
||||
&event,
|
||||
Event::Row {
|
||||
event: RowEvent::ValueBrowse(_, _, _) | RowEvent::ReferenceBrowse(_, _, _),
|
||||
..
|
||||
}
|
||||
);
|
||||
self.notice = None;
|
||||
let mut authored = true;
|
||||
let result = match event {
|
||||
Event::AcceptImpact(impact, accepted) => {
|
||||
authored = false;
|
||||
if !self
|
||||
.plan
|
||||
.as_ref()
|
||||
.is_some_and(|plan| plan.impacts.contains(&impact))
|
||||
{
|
||||
Err("This exact prepared change is no longer in the preview. Review the current changes before accepting.".into())
|
||||
} else {
|
||||
self.accepted_impacts
|
||||
.retain(|old| old.object != impact.object);
|
||||
if accepted {
|
||||
self.accepted_impacts.push(*impact);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Event::ChooseTrace(choice) => {
|
||||
self.add_trace = Some(choice);
|
||||
authored = false;
|
||||
Ok(())
|
||||
}
|
||||
Event::OpenTrace => self
|
||||
.add_trace
|
||||
.as_ref()
|
||||
.map(|choice| choice.id)
|
||||
.ok_or_else(|| "Choose a trace to add or reopen.".to_owned())
|
||||
.and_then(|id| self.open(id, false)),
|
||||
Event::Focus(address) => {
|
||||
authored = false;
|
||||
self.focus(address)
|
||||
}
|
||||
Event::Include(address, include) => self.include(address, include),
|
||||
Event::Direction(direction) => self.change_direction(direction, self.active.trace),
|
||||
Event::Editor(_) => {
|
||||
Err("Unaddressed editor message was rejected; choose its review row again.".into())
|
||||
}
|
||||
Event::Row {
|
||||
address,
|
||||
facet,
|
||||
event,
|
||||
} => {
|
||||
if self.row_mut(address).is_none() {
|
||||
Err("The originating review draft is no longer available.".into())
|
||||
} else {
|
||||
match event {
|
||||
#[cfg(test)]
|
||||
RowEvent::Trace(choice) => {
|
||||
if self.active_address != address {
|
||||
Err("A delayed trace selection belongs to another row; reopen that row first.".into())
|
||||
} else {
|
||||
self.open(choice.id, true)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
RowEvent::Direction(direction) => {
|
||||
let trace = self.row_mut(address).and_then(|row| row.trace);
|
||||
self.change_direction(direction, trace)
|
||||
}
|
||||
RowEvent::AddReference => {
|
||||
let row = self.row_mut(address).expect("Checked row");
|
||||
match (&row.reference_oa, &row.reference_sa) {
|
||||
(Some(oa), Some(sa)) => {
|
||||
let (oa, sa) = (oa.id, sa.id);
|
||||
self.references.insert(oa, sa);
|
||||
Ok(())
|
||||
}
|
||||
_ => {
|
||||
Err("Choose both an Operational and a System reference.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
RowEvent::RestoreReviewedReferences => {
|
||||
let pairs = history::reviewed_pairs(
|
||||
self.row_mut(address).expect("Checked row"),
|
||||
&base,
|
||||
);
|
||||
pairs.and_then(|pairs| {
|
||||
let mut merged = self.canonical_references()?;
|
||||
for (oa, sa) in &pairs {
|
||||
if merged.insert(*oa, *sa).is_some_and(|old| old != *sa) {
|
||||
return Err("A reviewed pair conflicts with the current explicit correspondence. No pairs were restored.".into());
|
||||
}
|
||||
}
|
||||
if self.direction == TransitionDirection::SaToOa && merged.values().collect::<BTreeSet<_>>().len() != merged.len() {
|
||||
return Err("Reviewed pairs would make reverse correspondence ambiguous. No pairs were restored.".into());
|
||||
}
|
||||
self.notice = Some(format!("Restored {} exact historical key pairs. Order observations alone do not establish correspondence.", pairs.len()));
|
||||
self.references.extend(pairs);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
RowEvent::RemoveReference(id) => {
|
||||
self.references.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::AcceptImpact(_, _) => {
|
||||
authored = false;
|
||||
Err("The prepared change could not be identified exactly; refresh its preview.".into())
|
||||
}
|
||||
event => {
|
||||
authored = !matches!(
|
||||
event,
|
||||
RowEvent::Select(_)
|
||||
| RowEvent::Tab(_)
|
||||
| RowEvent::ShowUnchanged(_)
|
||||
| RowEvent::ReferenceOa(_)
|
||||
| RowEvent::ReferenceSa(_)
|
||||
| RowEvent::ReferenceSearch(_)
|
||||
| RowEvent::Attachment(
|
||||
attachments::Event::Family(_)
|
||||
| attachments::Event::Pick(_, _)
|
||||
| attachments::Event::Search(_)
|
||||
)
|
||||
| RowEvent::Artifact(
|
||||
artifacts::Event::Pick(_, _) | artifacts::Event::Search(_)
|
||||
)
|
||||
| RowEvent::MemberFamily(_)
|
||||
| RowEvent::MemberOa(_)
|
||||
| RowEvent::MemberSa(_)
|
||||
| RowEvent::Literal(_)
|
||||
| RowEvent::LiteralSide(_, _)
|
||||
| RowEvent::EditPair(_)
|
||||
| RowEvent::AnnotationAddress(_, _)
|
||||
| RowEvent::AnnotationBrowse(_, _, _)
|
||||
| RowEvent::ValueBrowse(_, _, _)
|
||||
| RowEvent::ReferenceBrowse(_, _, _)
|
||||
| RowEvent::Preview
|
||||
);
|
||||
let scoped_edit = matches!(
|
||||
event,
|
||||
RowEvent::Input(_, _)
|
||||
| RowEvent::Text(_, _)
|
||||
| RowEvent::Shape(_, _)
|
||||
| RowEvent::Variant(_, _)
|
||||
| RowEvent::Array(_, _, _)
|
||||
| RowEvent::Append(_, _)
|
||||
| RowEvent::ValuePath(_, _)
|
||||
| RowEvent::ValueBrowse(_, _, _)
|
||||
| RowEvent::ReferenceBrowse(_, _, _)
|
||||
| RowEvent::OrderMove(_, _, _)
|
||||
| RowEvent::OrderReconcile(_)
|
||||
);
|
||||
let stage = matches!(
|
||||
event,
|
||||
RowEvent::StageMember
|
||||
| RowEvent::Attachment(attachments::Event::Stage)
|
||||
| RowEvent::Artifact(artifacts::Event::Stage)
|
||||
);
|
||||
let row = self.row_mut(address).expect("Checked row");
|
||||
let prior_references = stage.then(|| row.references.clone());
|
||||
let selected = row.selected.clone();
|
||||
let previous_error = row.error.take();
|
||||
if scoped_edit {
|
||||
row.selected = facet;
|
||||
}
|
||||
row.update(&base, event);
|
||||
if scoped_edit {
|
||||
row.selected = selected;
|
||||
}
|
||||
let result = row.error.clone().map_or(Ok(()), Err);
|
||||
if browser_only && result.is_ok() {
|
||||
row.error = previous_error;
|
||||
}
|
||||
if let Some(prior) = prior_references
|
||||
&& result.is_ok()
|
||||
{
|
||||
let changed: Vec<_> = row
|
||||
.references
|
||||
.iter()
|
||||
.filter(|(id, value)| prior.get(id) != Some(value))
|
||||
.map(|(id, value)| (*id, *value))
|
||||
.collect();
|
||||
self.references.extend(changed);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if authored {
|
||||
self.accepted_impacts.clear();
|
||||
}
|
||||
if !browser_only {
|
||||
self.refresh(project);
|
||||
}
|
||||
if let Err(error) = result {
|
||||
self.error = Some(error.clone());
|
||||
self.active.error = Some(error);
|
||||
}
|
||||
}
|
||||
pub(super) fn request(&self, _project: &Project) -> Result<SemanticTransitionRequest, String> {
|
||||
compose::request(self)
|
||||
}
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
if let Err(error) = self.frozen(project) {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
let base = Arc::clone(&self.base);
|
||||
let references = self.canonical_references();
|
||||
let context = references.as_ref().unwrap_or(&self.references).clone();
|
||||
for row in self
|
||||
.bank
|
||||
.values_mut()
|
||||
.chain(std::iter::once(&mut self.active))
|
||||
{
|
||||
row.references = context.clone();
|
||||
if let Some(trace) = row.trace.and_then(|id| base.traces.get(&id)) {
|
||||
row.references.insert(trace.oa, trace.sa);
|
||||
}
|
||||
row.refresh(&base);
|
||||
}
|
||||
let request = references.and_then(|_| self.request(&base));
|
||||
match request.and_then(|request| {
|
||||
plan_semantic_transition(&base, request).map_err(|error| error.to_string())
|
||||
}) {
|
||||
Ok(plan) => {
|
||||
if plan.conflicts.is_empty() {
|
||||
match prepare_semantic_transition(&base, &plan) {
|
||||
Ok(prepared) => self.proposed = Some(prepared.project),
|
||||
Err(error) => self.error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
self.plan = Some(plan);
|
||||
}
|
||||
Err(error) => self.error = Some(error),
|
||||
}
|
||||
self.active.plan = self.plan.clone();
|
||||
self.active.proposed = self.proposed.clone();
|
||||
self.active.accepted_impacts = self.accepted_impacts.clone();
|
||||
self.active.error = self.error.clone().or_else(|| {
|
||||
self.active
|
||||
.combined_value(&base, false)
|
||||
.err()
|
||||
.map(|error| format!("This row's retained draft: {error}"))
|
||||
});
|
||||
if let Some(value) = self
|
||||
.proposed
|
||||
.as_ref()
|
||||
.and_then(|project| {
|
||||
self.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id))
|
||||
})
|
||||
.map(|draft| draft.value)
|
||||
{
|
||||
if let Some(draft) = &mut self.active.draft {
|
||||
draft.set_context(Some(value.clone()));
|
||||
}
|
||||
for draft in self.active.group_drafts.values_mut() {
|
||||
draft.set_context(Some(value.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn command(&mut self, project: &Project) -> Result<Command, String> {
|
||||
self.frozen(project)?;
|
||||
if !self.has_decisions() {
|
||||
return Err("Choose at least one included property or exact history reset.".into());
|
||||
}
|
||||
if let Some(error) = &self.error {
|
||||
return Err(error.clone());
|
||||
}
|
||||
let plan = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.ok_or("Refresh the combined review preview before applying.")?;
|
||||
prepare_semantic_transition(project, plan).map_err(|error| error.to_string())?;
|
||||
Ok(Command::Batch(plan.commands.clone()))
|
||||
}
|
||||
fn has_decisions(&self) -> bool {
|
||||
self.included_addresses()
|
||||
.into_iter()
|
||||
.filter_map(|address| self.row(address))
|
||||
.any(|row| !row.decisions.is_empty() || !row.resets.is_empty())
|
||||
}
|
||||
pub fn can_apply(&self) -> bool {
|
||||
self.has_decisions()
|
||||
&& self.error.is_none()
|
||||
&& self.plan.as_ref().is_some_and(|plan| {
|
||||
plan.conflicts.is_empty() && plan.prepared_fingerprint.is_some()
|
||||
})
|
||||
}
|
||||
pub fn view<'a>(&'a self, _project: &'a Project) -> iced::Element<'a, Message> {
|
||||
use iced::{
|
||||
Fill,
|
||||
widget::{button, checkbox, column, pick_list, row, text},
|
||||
};
|
||||
let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event));
|
||||
let mut available: Vec<_> = self
|
||||
.base
|
||||
.traces
|
||||
.values()
|
||||
.map(|trace| Choice {
|
||||
id: trace.id,
|
||||
label: format!(
|
||||
"{} · {} ↔ {}",
|
||||
trace.name,
|
||||
self.base.named(trace.oa),
|
||||
self.base.named(trace.sa)
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
available.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
let choices: Vec<_> = self
|
||||
.order
|
||||
.iter()
|
||||
.filter(|address| address.direction(&self.base).ok() == Some(self.direction))
|
||||
.map(|address| Choice {
|
||||
id: address.trace,
|
||||
label: format!(
|
||||
"{} · {} → {} · {}",
|
||||
if self.included.contains(address) {
|
||||
"Included"
|
||||
} else {
|
||||
"Excluded"
|
||||
},
|
||||
self.base.named(address.source),
|
||||
self.base.named(address.target),
|
||||
self.base.named(address.trace)
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|choice| {
|
||||
self.active_address
|
||||
.is_some_and(|address| address.trace == choice.id)
|
||||
})
|
||||
.cloned();
|
||||
let direction = self.direction;
|
||||
let base = &self.base;
|
||||
let mut header = column![
|
||||
text(format!("Review semantics · {} included rows", self.included_addresses().len())).size(22),
|
||||
row![pick_list(available, self.add_trace.clone(), move |choice| message(Event::ChooseTrace(choice))).placeholder("Choose another trace to add or reopen").width(Fill).text_size(12).padding(7), button(text("Open row").size(12)).padding(7).style(crate::style::nav).on_press(message(Event::OpenTrace)), button(text(if direction == TransitionDirection::OaToSa { "OA → SA" } else { "SA → OA" }).size(12)).padding(7).style(crate::style::nav).on_press(message(Event::Direction(if direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })))].spacing(7),
|
||||
row![pick_list(choices, selected, move |choice| message(Event::Focus(RowAddress::from_trace(&base.traces[&choice.id], direction)))).placeholder("Choose a trace / review history").width(Fill).text_size(12).padding(7), checkbox(self.active_address.is_some_and(|address| self.included.contains(&address))).label("Include this row").on_toggle_maybe(self.active_address.map(|address| move |included| message(Event::Include(address, included))))].spacing(10),
|
||||
text("Switching rows retains every draft. Only included rows contribute to the one combined preview.").size(10).color(crate::style::MUTED),
|
||||
].spacing(6);
|
||||
if let Some(notice) = &self.notice {
|
||||
header = header.push(text(notice).size(11));
|
||||
}
|
||||
self.active
|
||||
.view(&self.base, header.into(), self.can_apply())
|
||||
.map(move |message| match message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
use super::*;
|
||||
use crate::{app::App, document::Document};
|
||||
|
||||
struct Ranges {
|
||||
project: Project,
|
||||
sources: [Id; 2],
|
||||
targets: [Id; 2],
|
||||
traces: [Id; 2],
|
||||
direction: TransitionDirection,
|
||||
}
|
||||
fn ranges(reverse: bool, converge: bool) -> Ranges {
|
||||
let mut project = Project::empty();
|
||||
let direction = if reverse {
|
||||
TransitionDirection::SaToOa
|
||||
} else {
|
||||
TransitionDirection::OaToSa
|
||||
};
|
||||
let mut add = |name, layer| {
|
||||
let id = project
|
||||
.add_element(Kind::DataType, layer, name, None)
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
minimum: Some(0),
|
||||
maximum: Some(100),
|
||||
},
|
||||
default: None,
|
||||
unit: "degrees".into(),
|
||||
};
|
||||
id
|
||||
};
|
||||
let sources = [
|
||||
add("Normal operation", direction.source_layer()),
|
||||
add("Emergency operation", direction.source_layer()),
|
||||
];
|
||||
let first = add("Controller limit", direction.target_layer());
|
||||
let second = if converge {
|
||||
first
|
||||
} else {
|
||||
add("Backup controller limit", direction.target_layer())
|
||||
};
|
||||
let targets = [first, second];
|
||||
let traces = std::array::from_fn(|i| {
|
||||
let (oa, sa) = if reverse {
|
||||
(targets[i], sources[i])
|
||||
} else {
|
||||
(sources[i], targets[i])
|
||||
};
|
||||
tests::add_trace(
|
||||
&mut project,
|
||||
oa,
|
||||
sa,
|
||||
if i == 0 {
|
||||
"Normal limit rationale"
|
||||
} else {
|
||||
"Emergency limit rationale"
|
||||
},
|
||||
)
|
||||
});
|
||||
Ranges {
|
||||
project,
|
||||
sources,
|
||||
targets,
|
||||
traces,
|
||||
direction,
|
||||
}
|
||||
}
|
||||
fn address(f: &Ranges, i: usize) -> RowAddress {
|
||||
RowAddress {
|
||||
source: f.sources[i],
|
||||
target: f.targets[i],
|
||||
trace: f.traces[i],
|
||||
}
|
||||
}
|
||||
fn send(state: &mut State, project: &Project, event: RowEvent) {
|
||||
let event = state.address_event(event);
|
||||
state.update(project, event);
|
||||
}
|
||||
fn open(state: &mut State, project: &Project, trace: Id) {
|
||||
state.update(project, Event::ChooseTrace(view::choice(project, trace)));
|
||||
state.update(project, Event::OpenTrace);
|
||||
}
|
||||
fn path(direction: TransitionDirection, leaf: &str) -> String {
|
||||
format!(
|
||||
"/body/{}/Shared/DataType/definition/Integer/{leaf}",
|
||||
if direction.target_layer() == Layer::Oa {
|
||||
"Oa"
|
||||
} else {
|
||||
"Sa"
|
||||
}
|
||||
)
|
||||
}
|
||||
fn set_bound(state: &mut State, f: &Ranges, facet: DataFacet, raw: &str) {
|
||||
let selector = FacetSelector::Data(facet);
|
||||
send(state, &f.project, RowEvent::Select(selector.clone()));
|
||||
send(
|
||||
state,
|
||||
&f.project,
|
||||
RowEvent::Decide(selector, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(
|
||||
state,
|
||||
&f.project,
|
||||
RowEvent::Input(
|
||||
path(
|
||||
f.direction,
|
||||
if facet == DataFacet::IntegerMinimum {
|
||||
"minimum"
|
||||
} else {
|
||||
"maximum"
|
||||
},
|
||||
),
|
||||
raw.into(),
|
||||
),
|
||||
);
|
||||
}
|
||||
fn bounds(project: &Project, target: Id) -> (Option<i64>, Option<i64>) {
|
||||
match project.elements[&target].shared().unwrap() {
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer { minimum, maximum },
|
||||
..
|
||||
} => (*minimum, *maximum),
|
||||
other => panic!("Unexpected target {other:?}"),
|
||||
}
|
||||
}
|
||||
fn errors(state: &State) -> String {
|
||||
format!(
|
||||
"{:?} / {:?}",
|
||||
state.error,
|
||||
state.plan.as_ref().map(|plan| &plan.conflicts)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_joint_bounds_use_one_candidate_and_match_an_independent_atomic_core_oracle() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "120");
|
||||
assert!(!state.can_apply(), "A single 120..100 row is invalid");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
|
||||
(Some(120), Some(150))
|
||||
);
|
||||
let mut transition = TransitionRequest::new(f.direction, f.sources.to_vec());
|
||||
transition.mappings = (0..2)
|
||||
.map(|i| TransitionMapping {
|
||||
source: f.sources[i],
|
||||
target: Some(f.targets[i]),
|
||||
target_kind: None,
|
||||
})
|
||||
.collect();
|
||||
let mut expected = SemanticTransitionRequest::new(transition);
|
||||
expected.references = (0..2).map(|i| (f.sources[i], f.targets[i])).collect();
|
||||
expected.traces = (0..2).map(|i| (f.sources[i], f.traces[i])).collect();
|
||||
expected.reviews = [DataFacet::IntegerMinimum, DataFacet::IntegerMaximum]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, facet)| SemanticReviewRequest {
|
||||
source: f.sources[i],
|
||||
resolutions: vec![FacetResolution {
|
||||
facet: FacetSelector::Data(facet),
|
||||
decision: SemanticDecision::Candidate,
|
||||
}],
|
||||
reset: vec![],
|
||||
})
|
||||
.collect();
|
||||
let mut candidate = f.project.elements[&f.targets[0]].clone();
|
||||
let SharedBody::DataType { definition, .. } = candidate.shared_mut().unwrap() else {
|
||||
unreachable!()
|
||||
};
|
||||
*definition = DataTypeDefinition::Integer {
|
||||
minimum: Some(120),
|
||||
maximum: Some(150),
|
||||
};
|
||||
expected.candidates.push(SemanticRecord::Element(candidate));
|
||||
let oracle = plan_semantic_transition(&f.project, expected).unwrap();
|
||||
assert!(oracle.conflicts.is_empty(), "{:?}", oracle.conflicts);
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap(),
|
||||
&prepare_semantic_transition(&f.project, &oracle)
|
||||
.unwrap()
|
||||
.project
|
||||
);
|
||||
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 1);
|
||||
let prepared = state.proposed.clone();
|
||||
state.order.reverse();
|
||||
state.refresh(&f.project);
|
||||
assert_eq!(
|
||||
state.proposed, prepared,
|
||||
"Display order must not choose the winning draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_candidate_conflict_keeps_both_raw_values_and_keep_is_a_real_constraint() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "7");
|
||||
assert!(!state.can_apply());
|
||||
let error = errors(&state);
|
||||
assert!(
|
||||
error.contains("Normal operation")
|
||||
&& error.contains("Emergency operation")
|
||||
&& error.contains("disagree"),
|
||||
"{error}"
|
||||
);
|
||||
let minimum = path(f.direction, "minimum");
|
||||
assert_eq!(
|
||||
state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&minimum]
|
||||
.raw,
|
||||
"5"
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.row(address(&f, 1))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&minimum]
|
||||
.raw,
|
||||
"7"
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Data(DataFacet::IntegerMinimum),
|
||||
Some(SemanticDecision::KeepTarget),
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
!state.can_apply(),
|
||||
"Keeping zero must conflict with another row's five"
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(FacetSelector::Data(DataFacet::IntegerMinimum), None),
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]).0,
|
||||
Some(5)
|
||||
);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
assert!(
|
||||
state.can_apply(),
|
||||
"Equal independently authored values agree"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_excluded_invalid_row_survives_focus_reopen_and_v2_recovery() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "-");
|
||||
assert!(!state.can_apply());
|
||||
state.update(&f.project, Event::Include(address(&f, 1), false));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
state.update(&f.project, Event::Focus(address(&f, 0)));
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
assert!(
|
||||
!state.included.contains(&address(&f, 1)),
|
||||
"Reopen is focus, not consent to include invalid work"
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Select(FacetSelector::Data(DataFacet::IntegerMinimum)),
|
||||
);
|
||||
assert!(
|
||||
state.can_apply(),
|
||||
"Selecting an excluded property must not promote its draft error to a batch failure"
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
assert_eq!(saved["version"], 2);
|
||||
let restored = State::from_recovery(&f.project, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert_eq!(
|
||||
restored
|
||||
.row(address(&f, 1))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&path(f.direction, "maximum")]
|
||||
.raw,
|
||||
"-"
|
||||
);
|
||||
assert!(restored.can_apply(), "{}", errors(&restored));
|
||||
assert_eq!(restored.proposed, state.proposed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_delayed_text_targets_its_original_row_and_facet_without_switching_focus() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
let late = state.address_event(RowEvent::Input(path(f.direction, "minimum"), "6".into()));
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
state.update(&f.project, late);
|
||||
assert_eq!(state.active_address, Some(address(&f, 1)));
|
||||
assert_eq!(
|
||||
state.selected,
|
||||
Some(FacetSelector::Data(DataFacet::IntegerMaximum))
|
||||
);
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
|
||||
(Some(6), Some(150))
|
||||
);
|
||||
state.update(&f.project, Event::Focus(address(&f, 0)));
|
||||
let late = state.address_event(RowEvent::Input(path(f.direction, "minimum"), "8".into()));
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
state.update(&f.project, late);
|
||||
assert_eq!(
|
||||
state.selected,
|
||||
Some(FacetSelector::Data(DataFacet::IntegerMaximum))
|
||||
);
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().inputs[&path(f.direction, "minimum")].raw,
|
||||
"8"
|
||||
);
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().inputs[&path(f.direction, "maximum")].raw,
|
||||
"150"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_reverse_distinct_targets_work_and_reverse_convergence_is_explicit() {
|
||||
for converge in [false, true] {
|
||||
let f = ranges(true, converge);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMaximum, "150");
|
||||
if converge {
|
||||
assert!(!state.can_apply());
|
||||
assert!(
|
||||
errors(&state).contains("Reverse convergence"),
|
||||
"{}",
|
||||
errors(&state)
|
||||
);
|
||||
assert_eq!(state.included_addresses().len(), 2);
|
||||
} else {
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]).0,
|
||||
Some(5)
|
||||
);
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[1]).1,
|
||||
Some(150)
|
||||
);
|
||||
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_v2_rejects_duplicate_included_sources_bad_addresses_and_stale_model() {
|
||||
let mut f = ranges(false, false);
|
||||
let alternate = tests::add_trace(
|
||||
&mut f.project,
|
||||
f.sources[0],
|
||||
f.targets[0],
|
||||
"Alternative rationale",
|
||||
);
|
||||
let mut state = State::new(&f.project, f.traces[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
open(&mut state, &f.project, alternate);
|
||||
let alternate_address = RowAddress {
|
||||
trace: alternate,
|
||||
..address(&f, 0)
|
||||
};
|
||||
assert!(!state.included.contains(&alternate_address));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let mut duplicate = saved.clone();
|
||||
duplicate["rows"][1]["included"] = Value::Bool(true);
|
||||
assert!(
|
||||
State::from_recovery(&f.project, duplicate)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("multiple included")
|
||||
);
|
||||
let mut duplicate = saved.clone();
|
||||
let extra = duplicate["rows"][0].clone();
|
||||
duplicate["rows"].as_array_mut().unwrap().push(extra);
|
||||
assert!(State::from_recovery(&f.project, duplicate).is_err());
|
||||
let mut bad = saved.clone();
|
||||
bad["active"]["target"] = serde_json::to_value(f.sources[0]).unwrap();
|
||||
assert!(State::from_recovery(&f.project, bad).is_err());
|
||||
let mut changed = f.project.clone();
|
||||
changed.name.push_str(" newer accepted revision");
|
||||
assert!(State::from_recovery(&changed, saved).is_err());
|
||||
assert!(state.command(&changed).is_err());
|
||||
let before = state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&path(f.direction, "minimum")]
|
||||
.raw
|
||||
.clone();
|
||||
state.update(&changed, Event::Focus(address(&f, 0)));
|
||||
assert_eq!(
|
||||
state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.draft
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.inputs[&path(f.direction, "minimum")]
|
||||
.raw,
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_constructor_retirement_conflicts_with_another_rows_kept_bound_in_both_orders() {
|
||||
for first in [0, 1] {
|
||||
let mut f = ranges(false, true);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&f.targets[0])
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real {
|
||||
minimum: Some(0.0),
|
||||
maximum: Some(100.0),
|
||||
},
|
||||
default: None,
|
||||
unit: "degrees".into(),
|
||||
};
|
||||
let mut state = State::new(&f.project, f.sources[first]).unwrap();
|
||||
for i in [first, 1 - first] {
|
||||
if state.active_address != Some(address(&f, i)) {
|
||||
open(&mut state, &f.project, f.traces[i]);
|
||||
}
|
||||
let facet = FacetSelector::Data(if i == 0 {
|
||||
DataFacet::Constructor
|
||||
} else {
|
||||
DataFacet::RealMinimum
|
||||
});
|
||||
send(&mut state, &f.project, RowEvent::Select(facet.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(
|
||||
facet,
|
||||
Some(if i == 0 {
|
||||
SemanticDecision::TakeSource
|
||||
} else {
|
||||
SemanticDecision::KeepTarget
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!state.can_apply(),
|
||||
"A constructor must not retire another row's kept bound"
|
||||
);
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
assert!(
|
||||
state
|
||||
.row(address(&f, 0))
|
||||
.unwrap()
|
||||
.decisions
|
||||
.contains_key(&FacetSelector::Data(DataFacet::Constructor))
|
||||
);
|
||||
assert!(
|
||||
state
|
||||
.row(address(&f, 1))
|
||||
.unwrap()
|
||||
.decisions
|
||||
.contains_key(&FacetSelector::Data(DataFacet::RealMinimum))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() {
|
||||
let f = ranges(false, true);
|
||||
let mut app = App::with_project(f.project.clone());
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(
|
||||
0,
|
||||
"// Two independent engineering rationales stay verbatim\r\n",
|
||||
);
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
tests::start(&mut app, f.sources[0]);
|
||||
tests::decide(
|
||||
&mut app,
|
||||
FacetSelector::Data(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
tests::send(
|
||||
&mut app,
|
||||
RowEvent::Input(path(f.direction, "minimum"), "120".into()),
|
||||
);
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
|
||||
)));
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
Event::OpenTrace,
|
||||
)));
|
||||
tests::decide(
|
||||
&mut app,
|
||||
FacetSelector::Data(DataFacet::IntegerMaximum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
tests::send(
|
||||
&mut app,
|
||||
RowEvent::Input(path(f.direction, "maximum"), "150".into()),
|
||||
);
|
||||
assert!(
|
||||
tests::state(&app).can_apply(),
|
||||
"{}",
|
||||
errors(tests::state(&app))
|
||||
);
|
||||
let expected = tests::state(&app).proposed.clone().unwrap();
|
||||
tests::apply(&mut app);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let after = app.document.snapshot();
|
||||
assert!(
|
||||
after.bundle.files[&after.bundle.entry]
|
||||
.starts_with("// Two independent engineering rationales stay verbatim\r\n")
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
assert_eq!(
|
||||
Document::from_bundle(
|
||||
after.bundle.clone(),
|
||||
source_project::ProjectLimits::default()
|
||||
)
|
||||
.unwrap()
|
||||
.project(),
|
||||
&expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_family_and_members_are_independent_core_checked_facets_both_directions() {
|
||||
for reverse in [false, true] {
|
||||
let mut project = Project::empty();
|
||||
let mut owners = vec![];
|
||||
let mut exchanges = vec![];
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let work = if layer == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let source = project
|
||||
.add_element(work, layer, "Issue command", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(work, layer, "Perform command", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(
|
||||
if layer == Layer::Oa {
|
||||
RelationKind::Interaction
|
||||
} else {
|
||||
RelationKind::FunctionalExchange
|
||||
},
|
||||
"Command",
|
||||
source,
|
||||
target,
|
||||
);
|
||||
let owner = project
|
||||
.add_element(Kind::ExchangeCategory, layer, "Command concern", None)
|
||||
.unwrap();
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::ExchangeCategory {
|
||||
family: oa_core::view_options::ExchangeFamily::Work,
|
||||
exchanges: vec![exchange],
|
||||
};
|
||||
owners.push(owner);
|
||||
exchanges.push(exchange);
|
||||
}
|
||||
tests::add_trace(&mut project, owners[0], owners[1], "Concern correspondence");
|
||||
validate_candidate(&project).unwrap();
|
||||
let mut state = State::new(&project, owners[usize::from(reverse)]).unwrap();
|
||||
state.references.insert(exchanges[0], exchanges[1]);
|
||||
state.refresh(&project);
|
||||
let member = FacetSelector::Member {
|
||||
collection: ReferenceCollection::CategoryExchanges,
|
||||
keys: KeyPair {
|
||||
oa: Some(exchanges[0]),
|
||||
sa: Some(exchanges[1]),
|
||||
},
|
||||
};
|
||||
let family = FacetSelector::Category(CategoryFacet::Family);
|
||||
assert!(state.editable(&project, &family));
|
||||
assert!(state.editable(&project, &member));
|
||||
send(&mut state, &project, RowEvent::Select(family.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Decide(family.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let target = owners[usize::from(!reverse)];
|
||||
let path = scalar::binding(&project, target, &family).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Shape(path, serde_json::json!("Participant")),
|
||||
);
|
||||
assert!(
|
||||
!state.can_apply(),
|
||||
"Work exchanges cannot belong to a Participant category"
|
||||
);
|
||||
send(&mut state, &project, RowEvent::Select(member.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&project,
|
||||
RowEvent::Decide(member.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut state, &project, RowEvent::Presence(member, false));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(
|
||||
matches!(state.proposed.as_ref().unwrap().elements[&target].shared().unwrap(), SharedBody::ExchangeCategory { family: oa_core::view_options::ExchangeFamily::Participant, exchanges } if exchanges.is_empty())
|
||||
);
|
||||
assert_eq!(
|
||||
prepare_command(&project, &state.command(&project).unwrap())
|
||||
.unwrap()
|
||||
.project,
|
||||
state.proposed.clone().unwrap()
|
||||
);
|
||||
let _ = state.view(&project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_direction_switch_at_row_limit_preserves_every_recoverable_draft() {
|
||||
let mut f = ranges(false, true);
|
||||
let mut traces = vec![f.traces[0]];
|
||||
for i in 1..batch::MAX_REVIEW_ROWS {
|
||||
traces.push(tests::add_trace(
|
||||
&mut f.project,
|
||||
f.sources[0],
|
||||
f.targets[0],
|
||||
&format!("Alternative {i}"),
|
||||
));
|
||||
}
|
||||
let mut state = State::new(&f.project, traces[0]).unwrap();
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "-");
|
||||
for trace in traces.into_iter().skip(1) {
|
||||
let row = ReviewRow::new(&f.project, trace).unwrap();
|
||||
let address = RowAddress {
|
||||
source: f.sources[0],
|
||||
target: f.targets[0],
|
||||
trace,
|
||||
};
|
||||
state.bank.insert(address, row);
|
||||
state.order.push(address);
|
||||
}
|
||||
let before = state.recovery_snapshot().unwrap();
|
||||
for event in [
|
||||
Event::Direction(TransitionDirection::SaToOa),
|
||||
state.address_event(RowEvent::Direction(TransitionDirection::SaToOa)),
|
||||
] {
|
||||
state.update(&f.project, event);
|
||||
assert_eq!(state.direction, TransitionDirection::OaToSa);
|
||||
assert!(state.error.as_ref().unwrap().contains("256"));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
assert_eq!(saved, before);
|
||||
let restored = State::from_recovery(&f.project, saved).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), before);
|
||||
assert_eq!(
|
||||
restored.draft.as_ref().unwrap().inputs[&path(f.direction, "minimum")].raw,
|
||||
"-"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_complete_constructor_cannot_erase_another_rows_new_literal() {
|
||||
let mut f = ranges(false, true);
|
||||
for id in f.sources.into_iter().chain(f.targets) {
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration {
|
||||
literals: vec!["A".into()],
|
||||
},
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let constructor = FacetSelector::Data(DataFacet::Constructor);
|
||||
let mut first = State::new(&f.project, f.sources[0]).unwrap();
|
||||
send(
|
||||
&mut first,
|
||||
&f.project,
|
||||
RowEvent::Decide(constructor.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(first.can_apply(), "{}", errors(&first));
|
||||
f.project = first.proposed.unwrap();
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(constructor, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
let literal = FacetSelector::Literal(KeyPair {
|
||||
oa: None,
|
||||
sa: Some("B".into()),
|
||||
});
|
||||
send(&mut state, &f.project, RowEvent::Select(literal.clone()));
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(literal.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut state, &f.project, RowEvent::Presence(literal, true));
|
||||
assert!(!state.can_apply());
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
state.update(&f.project, Event::Include(address(&f, 0), false));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert!(
|
||||
matches!(state.proposed.as_ref().unwrap().elements[&f.targets[0]].shared().unwrap(), SharedBody::DataType { definition: DataTypeDefinition::Enumeration { literals }, .. } if literals == &vec!["A".to_string(), "B".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_first_equal_constructor_acknowledgement_allows_an_independent_bound() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.sources[0]).unwrap();
|
||||
send(
|
||||
&mut state,
|
||||
&f.project,
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Data(DataFacet::Constructor),
|
||||
Some(SemanticDecision::KeepTarget),
|
||||
),
|
||||
);
|
||||
open(&mut state, &f.project, f.traces[1]);
|
||||
set_bound(&mut state, &f, DataFacet::IntegerMinimum, "5");
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(
|
||||
bounds(state.proposed.as_ref().unwrap(), f.targets[0]),
|
||||
(Some(5), Some(100))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovered_trace_picker_names_existing_trace_instead_of_deleted_reference() {
|
||||
let f = ranges(false, true);
|
||||
let mut state = State::new(&f.project, f.traces[0]).unwrap();
|
||||
state.update(
|
||||
&f.project,
|
||||
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
|
||||
);
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.project, saved).unwrap();
|
||||
let selected = restored.add_trace.as_ref().unwrap();
|
||||
assert_eq!(selected.id, f.traces[1]);
|
||||
assert!(
|
||||
selected.label.contains("Emergency limit rationale"),
|
||||
"{}",
|
||||
selected.label
|
||||
);
|
||||
assert!(!selected.label.contains("Deleted reference"));
|
||||
assert_eq!(selected.label, state.add_trace.as_ref().unwrap().label);
|
||||
assert!(
|
||||
view::choice(&f.project, Id::new_v4())
|
||||
.label
|
||||
.contains("Deleted reference")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
//! Compare independent authored intent before building the core's unique target candidates.
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "compose_diagnostic_tests.rs"]
|
||||
mod diagnostic_tests;
|
||||
|
||||
type Slot = (Id, FacetSelector);
|
||||
struct Intent {
|
||||
value: Value,
|
||||
row: RowAddress,
|
||||
origin: FacetSelector,
|
||||
}
|
||||
struct Proposal {
|
||||
address: RowAddress,
|
||||
value: Value,
|
||||
groups: Vec<FacetSelector>,
|
||||
copies: Vec<FacetSelector>,
|
||||
}
|
||||
|
||||
fn canonical(facet: &FacetSelector, layer: Layer) -> Option<FacetSelector> {
|
||||
fn key<T: Clone>(keys: &KeyPair<T>, layer: Layer) -> Option<KeyPair<T>> {
|
||||
Some(KeyPair {
|
||||
oa: None,
|
||||
sa: Some(
|
||||
if layer == Layer::Oa {
|
||||
keys.oa.as_ref()?
|
||||
} else {
|
||||
keys.sa.as_ref()?
|
||||
}
|
||||
.clone(),
|
||||
),
|
||||
})
|
||||
}
|
||||
Some(match facet {
|
||||
FacetSelector::Member { collection, keys } => FacetSelector::Member {
|
||||
collection: *collection,
|
||||
keys: key(keys, layer)?,
|
||||
},
|
||||
FacetSelector::Literal(keys) => FacetSelector::Literal(key(keys, layer)?),
|
||||
FacetSelector::Annotation(keys) => FacetSelector::Annotation(key(keys, layer)?),
|
||||
FacetSelector::Binding(keys) => FacetSelector::Binding(key(keys, layer)?),
|
||||
FacetSelector::Child(_)
|
||||
| FacetSelector::Incident { .. }
|
||||
| FacetSelector::Artifact { .. } => return None,
|
||||
_ => facet.clone(),
|
||||
})
|
||||
}
|
||||
fn observe(project: &Project, slot: &Slot) -> Result<Value, String> {
|
||||
if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
return Ok(artifacts::observation(
|
||||
project.artifact_links.get(&slot.0),
|
||||
facet,
|
||||
));
|
||||
}
|
||||
// A retired destination slot can legitimately apply on neither side of this
|
||||
// synthetic target-only observation. Public capture_facet intentionally rejects
|
||||
// such historical facts; determine absence through the same closed UI binding.
|
||||
let applicable = if let Some(family) = keyed::family(&slot.1) {
|
||||
keyed::path(&record_value(project, slot.0)?, family).is_some()
|
||||
} else if let FacetSelector::Order(which) = slot.1 {
|
||||
ordered::read(&record_value(project, slot.0)?, which).is_ok()
|
||||
} else {
|
||||
scalar::binding(project, slot.0, &slot.1).is_some()
|
||||
};
|
||||
if !applicable {
|
||||
return Ok(Value::String("NotApplicable".into()));
|
||||
}
|
||||
Ok(scalar::observation(
|
||||
&capture_facet(project, slot.0, slot.0, &slot.1)?,
|
||||
Layer::Sa,
|
||||
))
|
||||
}
|
||||
fn record_value(project: &Project, id: Id) -> Result<Value, String> {
|
||||
Draft::from_project(project, id)
|
||||
.map(|draft| draft.value)
|
||||
.ok_or("Target has no editable semantic record.".into())
|
||||
}
|
||||
fn install(project: &mut Project, id: Id, value: Value) -> Result<(), String> {
|
||||
if let std::collections::btree_map::Entry::Occupied(mut entry) = project.elements.entry(id) {
|
||||
let record: Element = serde_json::from_value(value)
|
||||
.map_err(|error| format!("Final target value: {error}"))?;
|
||||
if record.id != id {
|
||||
return Err("Candidate identity changed.".into());
|
||||
}
|
||||
entry.insert(record);
|
||||
} else if let std::collections::btree_map::Entry::Occupied(mut entry) =
|
||||
project.relations.entry(id)
|
||||
{
|
||||
let record: Relation = serde_json::from_value(value)
|
||||
.map_err(|error| format!("Final target value: {error}"))?;
|
||||
if record.id != id {
|
||||
return Err("Candidate identity changed.".into());
|
||||
}
|
||||
entry.insert(record);
|
||||
} else if let std::collections::btree_map::Entry::Occupied(mut entry) =
|
||||
project.artifact_links.entry(id)
|
||||
{
|
||||
let record: ArtifactLink = serde_json::from_value(value)
|
||||
.map_err(|error| format!("Final artifact value: {error}"))?;
|
||||
if record.id != id {
|
||||
return Err("Candidate artifact identity changed.".into());
|
||||
}
|
||||
entry.insert(record);
|
||||
} else {
|
||||
return Err("The batch foundation requires an existing target record.".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn identity(base: &Project, id: Id) -> String {
|
||||
base.identities()
|
||||
.into_iter()
|
||||
.find(|(key, _)| *key == id)
|
||||
.map_or_else(
|
||||
|| format!("Missing identity {id}"),
|
||||
|(_, alias)| format!("{} · @{alias}", base.named(id)),
|
||||
)
|
||||
}
|
||||
fn review_identity(state: &State, row: RowAddress, id: Id) -> String {
|
||||
if !state.base.contains(id)
|
||||
&& let Some(review) = state.row(row)
|
||||
&& review.artifacts.seeds.contains_key(&id)
|
||||
{
|
||||
return review.review_choice(&state.base, id).label;
|
||||
}
|
||||
identity(&state.base, id)
|
||||
}
|
||||
fn review_title(state: &State, row: RowAddress, facet: &FacetSelector) -> String {
|
||||
state.row(row).map_or_else(
|
||||
|| view::title(&state.base, facet),
|
||||
|review| review.review_title(&state.base, facet),
|
||||
)
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn describe(base: &Project, slot: &Slot, value: &Value) -> String {
|
||||
describe_with(slot, value, |id| identity(base, id))
|
||||
}
|
||||
fn describe_with(slot: &Slot, value: &Value, identify: impl Fn(Id) -> String) -> String {
|
||||
let text = if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
if value.as_str() == Some("NotApplicable") {
|
||||
"artifact absent".into()
|
||||
} else if facet == ArtifactFacet::Owner {
|
||||
value
|
||||
.get("Value")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|s| s.parse::<Id>().ok())
|
||||
.map(|id| format!("owner {}", identify(id)))
|
||||
.unwrap_or_else(|| value.to_string())
|
||||
} else {
|
||||
value.get("Value").unwrap_or(value).to_string()
|
||||
}
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
if text.chars().count() > 400 {
|
||||
format!(
|
||||
"{}… (full draft retained)",
|
||||
text.chars().take(400).collect::<String>()
|
||||
)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
fn agree(
|
||||
state: &State,
|
||||
slots: &mut BTreeMap<Slot, Intent>,
|
||||
slot: Slot,
|
||||
value: Value,
|
||||
row: RowAddress,
|
||||
origin: &FacetSelector,
|
||||
) -> Result<(), String> {
|
||||
let base = &state.base;
|
||||
if let Some(previous) = slots.get(&slot) {
|
||||
if previous.value != value {
|
||||
let same_row = previous.row == row;
|
||||
let heading = if same_row {
|
||||
"Decisions in this review disagree"
|
||||
} else {
|
||||
"Independent row proposals disagree"
|
||||
};
|
||||
let scope = artifacts::parts(&slot.1).map_or_else(
|
||||
|| view::title(base, &slot.1),
|
||||
|(_, facet)| format!("Artifact {facet:?}"),
|
||||
);
|
||||
let first_row = format!(
|
||||
"{} → {} [trace {}]",
|
||||
identity(base, previous.row.source),
|
||||
identity(base, previous.row.target),
|
||||
identity(base, previous.row.trace)
|
||||
);
|
||||
let second_row = if same_row {
|
||||
"the same review".into()
|
||||
} else {
|
||||
format!(
|
||||
"{} → {} [trace {}]",
|
||||
identity(base, row.source),
|
||||
identity(base, row.target),
|
||||
identity(base, row.trace)
|
||||
)
|
||||
};
|
||||
let action = if same_row {
|
||||
"Edit or unselect one of these decisions."
|
||||
} else {
|
||||
"Edit or unselect a decision, or exclude a row."
|
||||
};
|
||||
return Err(format!(
|
||||
"{heading} for {} · {scope}: {} in {first_row} requires {}; {} in {second_row} requires {}. {action} Both drafts are retained.",
|
||||
review_identity(state, row, slot.0),
|
||||
review_title(state, previous.row, &previous.origin),
|
||||
describe_with(&slot, &previous.value, |id| review_identity(
|
||||
state,
|
||||
previous.row,
|
||||
id
|
||||
)),
|
||||
review_title(state, row, origin),
|
||||
describe_with(&slot, &value, |id| review_identity(state, row, id))
|
||||
));
|
||||
}
|
||||
} else {
|
||||
slots.insert(
|
||||
slot,
|
||||
Intent {
|
||||
value,
|
||||
row,
|
||||
origin: origin.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn write_slot(project: &mut Project, slot: &Slot, observation: &Value) -> Result<(), String> {
|
||||
if let Some((_, facet)) = artifacts::parts(&slot.1) {
|
||||
if observation.as_str() == Some("NotApplicable") {
|
||||
if facet == ArtifactFacet::Owner {
|
||||
project.artifact_links.remove(&slot.0);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut value = record_value(project, slot.0)?;
|
||||
let field =
|
||||
artifacts::field(facet).ok_or("Artifact actual slot must identify metadata.")?;
|
||||
value[field] = observation
|
||||
.get("Value")
|
||||
.cloned()
|
||||
.ok_or("Artifact observation lacks typed value.")?;
|
||||
return install(project, slot.0, value);
|
||||
}
|
||||
if scalar::grouped(&slot.1) || observation.as_str() == Some("NotApplicable") {
|
||||
return Ok(());
|
||||
}
|
||||
let supplied = observation
|
||||
.get("Value")
|
||||
.ok_or("Missing typed destination observation.")?
|
||||
.clone();
|
||||
let mut value = record_value(project, slot.0)?;
|
||||
if keyed_rows::supported(&slot.1) {
|
||||
let key = keyed::key(&slot.1, Layer::Sa).ok_or("Destination row key missing.")?;
|
||||
let path = keyed::path(&value, keyed::family(&slot.1).expect("Supported family"))
|
||||
.ok_or("Destination row collection is no longer applicable.")?;
|
||||
let rows = value
|
||||
.pointer_mut(&path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("Destination rows missing.")?;
|
||||
let index = rows.iter().position(|row| match &slot.1 {
|
||||
FacetSelector::Annotation(_) => {
|
||||
serde_json::json!({"path":row["path"],"field":row["field"]}) == key
|
||||
}
|
||||
FacetSelector::Binding(_) => row["referenced"] == key,
|
||||
_ => false,
|
||||
});
|
||||
match (index, supplied.is_null()) {
|
||||
(Some(index), false) => rows[index] = supplied,
|
||||
(Some(index), true) => {
|
||||
rows.remove(index);
|
||||
}
|
||||
(None, false) => rows.push(supplied),
|
||||
(None, true) => {}
|
||||
}
|
||||
} else if keyed::supported(&slot.1) {
|
||||
keyed::project_presence(
|
||||
&mut value,
|
||||
&slot.1,
|
||||
Layer::Sa,
|
||||
supplied.as_bool().ok_or("Expected typed presence.")?,
|
||||
)?;
|
||||
} else if let FacetSelector::Order(which) = slot.1 {
|
||||
let order: Vec<Id> = serde_json::from_value(supplied).map_err(|error| error.to_string())?;
|
||||
let current = ordered::read(&value, which)?;
|
||||
if order.len() != order.iter().collect::<BTreeSet<_>>().len()
|
||||
|| order.iter().collect::<BTreeSet<_>>() != current.iter().collect::<BTreeSet<_>>()
|
||||
{
|
||||
return Err("Combined ordered membership needs an explicit final target order containing each surviving member once. Review membership explicitly in the order's row.".into());
|
||||
}
|
||||
let path = keyed::path(&value, keyed::Family::Member(ordered::collection(which)))
|
||||
.ok_or("Order collection missing.")?;
|
||||
*value.pointer_mut(&path).expect("Closed collection path") =
|
||||
serde_json::to_value(order).expect("Order");
|
||||
} else {
|
||||
let path = scalar::binding(project, slot.0, &slot.1)
|
||||
.ok_or("A selected property was retired by another row's constructor proposal.")?;
|
||||
*value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Selected property is no longer available.")? = supplied;
|
||||
}
|
||||
install(project, slot.0, value)
|
||||
}
|
||||
|
||||
pub(super) fn request(state: &State) -> Result<SemanticTransitionRequest, String> {
|
||||
let base = &state.base;
|
||||
let addresses = state.included_addresses();
|
||||
if addresses.is_empty() {
|
||||
return Err("Include at least one existing trace row in the combined review.".into());
|
||||
}
|
||||
let references = state.canonical_references()?;
|
||||
let mut transition = TransitionRequest::new(
|
||||
state.direction,
|
||||
addresses.iter().map(|address| address.source).collect(),
|
||||
);
|
||||
transition.mappings = addresses
|
||||
.iter()
|
||||
.map(|address| TransitionMapping {
|
||||
source: address.source,
|
||||
target: Some(address.target),
|
||||
target_kind: None,
|
||||
})
|
||||
.collect();
|
||||
let mut request = SemanticTransitionRequest::new(transition);
|
||||
request.references = references.clone();
|
||||
request.accepted_impacts = state.accepted_impacts.clone();
|
||||
let mut slots = BTreeMap::new();
|
||||
let mut proposals = vec![];
|
||||
let mut candidate_targets = BTreeSet::new();
|
||||
let mut implicit_orders = vec![];
|
||||
let mut artifact_creations = BTreeMap::new();
|
||||
for address in addresses {
|
||||
let row = state
|
||||
.row(address)
|
||||
.ok_or("Included row draft is unavailable.")?;
|
||||
let trace = row
|
||||
.effective_trace(base)
|
||||
.ok_or("Included trace is unavailable.")?;
|
||||
request.traces.insert(address.source, address.trace);
|
||||
request.reviews.push(SemanticReviewRequest {
|
||||
source: address.source,
|
||||
resolutions: row
|
||||
.decisions
|
||||
.iter()
|
||||
.map(|(facet, decision)| FacetResolution {
|
||||
facet: facet.clone(),
|
||||
decision: *decision,
|
||||
})
|
||||
.collect(),
|
||||
reset: row.resets.iter().cloned().collect(),
|
||||
});
|
||||
if row.decisions.iter().any(|(facet, decision)| {
|
||||
!attachments::supported(facet)
|
||||
&& !artifacts::supported(facet)
|
||||
&& *decision == SemanticDecision::Candidate
|
||||
}) {
|
||||
candidate_targets.insert(address.target);
|
||||
}
|
||||
let final_value = row.combined_value(base, false).map_err(|error| {
|
||||
format!(
|
||||
"{} [{}]: {error}",
|
||||
base.named(address.source),
|
||||
base.named(address.trace)
|
||||
)
|
||||
})?;
|
||||
let mut proposed = (**base).clone();
|
||||
install(&mut proposed, address.target, final_value.clone())?;
|
||||
let artifact =
|
||||
row.artifacts
|
||||
.propose(base, &trace, state.direction, &references, &row.decisions)?;
|
||||
for (slot, observation, origin) in artifact.slots {
|
||||
agree(state, &mut slots, slot, observation, address, &origin)?;
|
||||
}
|
||||
for seed in artifact.creations {
|
||||
if artifact_creations
|
||||
.insert(seed.id, seed.clone())
|
||||
.is_some_and(|prior| prior != seed)
|
||||
{
|
||||
return Err(
|
||||
"Independent artifact creation records disagree. All drafts are retained."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidate_targets.extend(artifact.candidates);
|
||||
let mut groups = vec![];
|
||||
let mut copies = vec![];
|
||||
for (facet, decision) in &row.decisions {
|
||||
if artifacts::supported(facet) {
|
||||
continue;
|
||||
}
|
||||
if attachments::supported(facet) {
|
||||
let attachment = row.attachments.propose(
|
||||
base,
|
||||
&trace,
|
||||
state.direction,
|
||||
facet,
|
||||
*decision,
|
||||
false,
|
||||
)?;
|
||||
let mut record = record_value(&proposed, attachment.slot.0)?;
|
||||
*record
|
||||
.pointer_mut(&attachment.path)
|
||||
.ok_or("Attachment field was retired by another selected decision.")? =
|
||||
attachment
|
||||
.record
|
||||
.pointer(&attachment.path)
|
||||
.cloned()
|
||||
.ok_or("Attachment proposal is incomplete.")?;
|
||||
install(&mut proposed, attachment.slot.0, record)?;
|
||||
let observed = observe(&proposed, &attachment.slot)?;
|
||||
agree(
|
||||
state,
|
||||
&mut slots,
|
||||
attachment.slot.clone(),
|
||||
observed,
|
||||
address,
|
||||
facet,
|
||||
)?;
|
||||
if *decision == SemanticDecision::Candidate {
|
||||
candidate_targets.insert(attachment.slot.0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(selector) = canonical(facet, state.direction.target_layer()) else {
|
||||
continue;
|
||||
};
|
||||
let slot = (address.target, selector);
|
||||
let observed = if *decision == SemanticDecision::KeepTarget {
|
||||
observe(base, &slot)?
|
||||
} else {
|
||||
observe(&proposed, &slot)?
|
||||
};
|
||||
agree(state, &mut slots, slot.clone(), observed, address, facet)?;
|
||||
let first_equal = row.can_acknowledge_constructor(base, facet)
|
||||
&& (*decision != SemanticDecision::Candidate
|
||||
|| observe(base, &slot)? == observe(&proposed, &slot)?);
|
||||
if scalar::grouped(facet) && (*decision == SemanticDecision::Candidate || !first_equal)
|
||||
{
|
||||
copies.push(facet.clone());
|
||||
}
|
||||
if scalar::grouped(facet) && first_equal && *decision == SemanticDecision::Candidate {
|
||||
// Preserve authored payload changes even when the first equal-kind
|
||||
// decision acknowledges only its discriminator. The core must see
|
||||
// these changes and require separate descendant decisions.
|
||||
let mut catalog = semantic_facets(base, &trace, &references)?;
|
||||
catalog.extend(semantic_facets(&proposed, &trace, &references)?);
|
||||
for fact in catalog {
|
||||
if constructors::contains(facet, &fact.selector)
|
||||
&& let Some(selector) =
|
||||
canonical(&fact.selector, state.direction.target_layer())
|
||||
{
|
||||
let slot = (address.target, selector);
|
||||
let supplied = observe(&proposed, &slot)?;
|
||||
if supplied != observe(base, &slot)? {
|
||||
agree(state, &mut slots, slot, supplied, address, facet)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if scalar::grouped(facet) && !first_equal {
|
||||
groups.push(facet.clone());
|
||||
let own = if *decision == SemanticDecision::KeepTarget {
|
||||
&**base
|
||||
} else {
|
||||
&proposed
|
||||
};
|
||||
let mut catalog = semantic_facets(base, &trace, &references)?;
|
||||
catalog.extend(semantic_facets(own, &trace, &references)?);
|
||||
for fact in catalog {
|
||||
if constructors::contains(facet, &fact.selector)
|
||||
&& let Some(selector) =
|
||||
canonical(&fact.selector, state.direction.target_layer())
|
||||
{
|
||||
let slot = (address.target, selector);
|
||||
agree(
|
||||
state,
|
||||
&mut slots,
|
||||
slot.clone(),
|
||||
observe(own, &slot)?,
|
||||
address,
|
||||
facet,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for which in [
|
||||
OrderedCollection::Steps,
|
||||
OrderedCollection::FragmentBranches,
|
||||
] {
|
||||
if !row.decisions.contains_key(&FacetSelector::Order(which))
|
||||
&& let Ok(order) = ordered::read(&record_value(&proposed, address.target)?, which)
|
||||
&& ordered::read(&record_value(base, address.target)?, which)
|
||||
.ok()
|
||||
.as_ref()
|
||||
!= Some(&order)
|
||||
{
|
||||
implicit_orders.push((address.target, which, order));
|
||||
}
|
||||
}
|
||||
proposals.push(Proposal {
|
||||
address,
|
||||
value: final_value,
|
||||
groups,
|
||||
copies,
|
||||
});
|
||||
}
|
||||
// Recheck every selected slot against every complete constructor scope, including
|
||||
// new keys that were absent from that constructor row's own catalog.
|
||||
let selected: Vec<_> = slots.keys().cloned().collect();
|
||||
for proposal in &proposals {
|
||||
if proposal.groups.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut proposed = (**base).clone();
|
||||
install(
|
||||
&mut proposed,
|
||||
proposal.address.target,
|
||||
proposal.value.clone(),
|
||||
)?;
|
||||
let row = state.row(proposal.address).expect("Included row");
|
||||
for group in &proposal.groups {
|
||||
let own = if row.decisions[group] == SemanticDecision::KeepTarget {
|
||||
&**base
|
||||
} else {
|
||||
&proposed
|
||||
};
|
||||
for slot in &selected {
|
||||
if slot.0 == proposal.address.target && constructors::contains(group, &slot.1) {
|
||||
agree(
|
||||
state,
|
||||
&mut slots,
|
||||
slot.clone(),
|
||||
observe(own, slot)?,
|
||||
proposal.address,
|
||||
group,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut merged = (**base).clone();
|
||||
merged.artifact_links.extend(artifact_creations);
|
||||
let mut groups: Vec<_> = proposals
|
||||
.iter()
|
||||
.flat_map(|proposal| proposal.copies.iter().map(move |group| (proposal, group)))
|
||||
.collect();
|
||||
groups.sort_by_key(|(proposal, group)| {
|
||||
(
|
||||
proposal.address.target,
|
||||
matches!(
|
||||
group,
|
||||
FacetSelector::Occurrence(OccurrenceFacet::MessageKind)
|
||||
),
|
||||
)
|
||||
});
|
||||
for (proposal, group) in groups {
|
||||
let row = state.row(proposal.address).expect("Included row");
|
||||
if row.decisions[group] == SemanticDecision::KeepTarget {
|
||||
continue;
|
||||
}
|
||||
let mut value = record_value(&merged, proposal.address.target)?;
|
||||
let supplied = &proposal.value;
|
||||
for path in constructors::roots(base, proposal.address.target, group)
|
||||
.ok_or("Constructor scope is unavailable.")?
|
||||
{
|
||||
*value
|
||||
.pointer_mut(&path)
|
||||
.ok_or("Overlapping constructors retire an authored scope.")? = supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Constructor candidate is incomplete.")?;
|
||||
}
|
||||
install(&mut merged, proposal.address.target, value)?;
|
||||
}
|
||||
let mut ordered_slots: Vec<_> = slots.iter().collect();
|
||||
ordered_slots.sort_by_key(|((_, facet), _)| matches!(facet, FacetSelector::Order(_)));
|
||||
for (slot, intention) in ordered_slots {
|
||||
write_slot(&mut merged, slot, &intention.value)?;
|
||||
}
|
||||
let mut final_orders = BTreeMap::new();
|
||||
for (target, which, mut order) in implicit_orders {
|
||||
if slots.contains_key(&(target, FacetSelector::Order(which))) {
|
||||
continue;
|
||||
}
|
||||
let surviving: BTreeSet<_> = ordered::read(&record_value(&merged, target)?, which)?
|
||||
.into_iter()
|
||||
.collect();
|
||||
order.retain(|id| surviving.contains(id));
|
||||
if order.iter().copied().collect::<BTreeSet<_>>() != surviving {
|
||||
return Err("Combined ordered membership needs an explicit final target order. Retained row drafts are unchanged.".into());
|
||||
}
|
||||
if final_orders
|
||||
.insert((target, which), order.clone())
|
||||
.is_some_and(|previous| previous != order)
|
||||
{
|
||||
return Err("Contradictory anchored orders need an explicit final target order. Retained row drafts are unchanged.".into());
|
||||
}
|
||||
}
|
||||
for ((target, which), order) in final_orders {
|
||||
write_slot(
|
||||
&mut merged,
|
||||
&(target, FacetSelector::Order(which)),
|
||||
&serde_json::json!({"Value":order}),
|
||||
)?;
|
||||
}
|
||||
for (slot, intention) in &slots {
|
||||
if observe(&merged, slot)? != intention.value {
|
||||
return Err(format!(
|
||||
"Combined candidate does not preserve the selected intent for {} · {}.",
|
||||
base.named(slot.0),
|
||||
view::title(base, &slot.1)
|
||||
));
|
||||
}
|
||||
}
|
||||
for target in candidate_targets {
|
||||
request
|
||||
.candidates
|
||||
.push(semantic_record(&merged, target).ok_or("Merged candidate target disappeared.")?);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> (Project, RowAddress, Id, Id, KeyPair<Id>) {
|
||||
let mut p = Project::empty();
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Inspect equipment", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor equipment", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Report condition", None)
|
||||
.unwrap();
|
||||
let trace = super::super::tests::add_trace(&mut p, source, target, "Evidence rationale");
|
||||
let mut ids = Vec::new();
|
||||
for (owner, alias) in [
|
||||
(source, "operational_evidence"),
|
||||
(target, "system_evidence"),
|
||||
] {
|
||||
let id = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
id,
|
||||
ArtifactLink {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: "Evidence".into(),
|
||||
description: String::new(),
|
||||
owner,
|
||||
relation: ArtifactRelation::Verifies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
(
|
||||
p,
|
||||
RowAddress {
|
||||
source,
|
||||
target,
|
||||
trace,
|
||||
},
|
||||
ids[1],
|
||||
other,
|
||||
KeyPair {
|
||||
oa: Some(ids[0]),
|
||||
sa: Some(ids[1]),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn send(state: &mut State, p: &Project, event: RowEvent) {
|
||||
let addressed = state.address_event(event);
|
||||
state.update(p, addressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_review_presence_and_owner_conflict_names_both_facets_and_owners_and_retains_drafts() {
|
||||
let (p, row, id, other, keys) = fixture();
|
||||
let accepted = p.clone();
|
||||
let mut state = State::new(&p, row.trace).unwrap();
|
||||
for (layer, key) in [(Layer::Oa, keys.oa), (Layer::Sa, keys.sa)] {
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
layer,
|
||||
key.map(|id| view::choice(&p, id)),
|
||||
)),
|
||||
);
|
||||
}
|
||||
send(&mut state, &p, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
let presence = artifacts::selector(&keys, ArtifactFacet::Presence);
|
||||
let owner = artifacts::selector(&keys, ArtifactFacet::Owner);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(owner.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Input(
|
||||
owner.clone(),
|
||||
artifacts::Raw::Owner(other.to_string()),
|
||||
)),
|
||||
);
|
||||
assert!(state.can_apply(), "{:?}", state.error);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(presence.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(state.command(&p).is_err());
|
||||
let error = state.error.as_deref().unwrap();
|
||||
assert!(
|
||||
error.starts_with("Decisions in this review disagree"),
|
||||
"{error}"
|
||||
);
|
||||
for expected in [
|
||||
"Artifact Presence",
|
||||
"Artifact Owner",
|
||||
"Monitor equipment",
|
||||
"Report condition",
|
||||
"Evidence rationale",
|
||||
"@system_evidence",
|
||||
"@operational_evidence",
|
||||
"Both drafts are retained",
|
||||
] {
|
||||
assert!(error.contains(expected), "missing {expected}: {error}");
|
||||
}
|
||||
for misleading in ["Independent row", "Absent", "{\"Value\"", "exclude a row"] {
|
||||
assert!(!error.contains(misleading), "{error}");
|
||||
}
|
||||
assert!(
|
||||
!error.contains(&row.target.to_string()),
|
||||
"named owner should not be raw UUID: {error}"
|
||||
);
|
||||
assert_eq!(state.decisions[&presence], SemanticDecision::KeepTarget);
|
||||
assert_eq!(
|
||||
state.artifacts.drafts[&owner],
|
||||
artifacts::Raw::Owner(other.to_string())
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Decide(presence, None));
|
||||
let command = state.command(&p).unwrap();
|
||||
let prepared = prepare_command(&p, &command).unwrap().project;
|
||||
assert_eq!(prepared.artifact_links[&id].owner, other);
|
||||
assert_eq!(p, accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_review_conflict_keeps_trace_aliases_even_when_names_are_equal() {
|
||||
let (mut p, first, id, other, keys) = fixture();
|
||||
let trace =
|
||||
super::super::tests::add_trace(&mut p, first.source, first.target, "Evidence rationale");
|
||||
let second = RowAddress { trace, ..first };
|
||||
let slot = artifacts::slot(id, ArtifactFacet::Owner);
|
||||
let presence = artifacts::selector(&keys, ArtifactFacet::Presence);
|
||||
let owner = artifacts::selector(&keys, ArtifactFacet::Owner);
|
||||
let mut slots = BTreeMap::new();
|
||||
let state = State::new(&p, first.trace).unwrap();
|
||||
agree(
|
||||
&state,
|
||||
&mut slots,
|
||||
slot.clone(),
|
||||
json!({"Value": first.target}),
|
||||
first,
|
||||
&presence,
|
||||
)
|
||||
.unwrap();
|
||||
let error = agree(
|
||||
&state,
|
||||
&mut slots,
|
||||
slot,
|
||||
json!({"Value": other}),
|
||||
second,
|
||||
&owner,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.starts_with("Independent row proposals disagree"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains(&p.traces[&first.trace].alias), "{error}");
|
||||
assert!(error.contains(&p.traces[&second.trace].alias), "{error}");
|
||||
assert!(
|
||||
error.contains("Artifact Presence") && error.contains("Artifact Owner"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("exclude a row"), "{error}");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(
|
||||
slots.values().next().unwrap().value,
|
||||
json!({"Value": first.target})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_creation_conflict_uses_its_rows_seed_instead_of_a_deleted_reference() {
|
||||
for reverse in [false, true] {
|
||||
let (p, row, _, _, existing) = fixture();
|
||||
let accepted = p.clone();
|
||||
let mut state = State::new(&p, row.trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&p, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
let source_layer = if reverse { Layer::Sa } else { Layer::Oa };
|
||||
let source = if reverse {
|
||||
existing.sa.unwrap()
|
||||
} else {
|
||||
existing.oa.unwrap()
|
||||
};
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
source_layer,
|
||||
Some(view::choice(&p, source)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
let keys = state.artifacts.pair();
|
||||
let destination = if reverse {
|
||||
keys.oa.unwrap()
|
||||
} else {
|
||||
keys.sa.unwrap()
|
||||
};
|
||||
let seed = state.artifacts.seeds[&destination].clone();
|
||||
let presence = artifacts::selector(&keys, ArtifactFacet::Presence);
|
||||
let owner = artifacts::selector(&keys, ArtifactFacet::Owner);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(presence, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Decide(owner, Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(!state.can_apply());
|
||||
assert!(state.command(&p).is_err());
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
for review in [&state, &restored] {
|
||||
let error = review.error.as_deref().unwrap();
|
||||
for expected in [
|
||||
"Decisions in this review disagree",
|
||||
"Artifact Presence",
|
||||
"Artifact Owner",
|
||||
"artifact absent",
|
||||
"Evidence · reserved @",
|
||||
] {
|
||||
assert!(error.contains(expected), "missing {expected}: {error}");
|
||||
}
|
||||
assert!(error.contains(&seed.alias), "{error}");
|
||||
for misleading in ["Missing identity", "Deleted reference", "{\"Value\""] {
|
||||
assert!(!error.contains(misleading), "{error}");
|
||||
}
|
||||
let actual = review.active_address.unwrap();
|
||||
assert!(review_identity(review, actual, destination).contains(&seed.alias));
|
||||
// A resolver for a different row must not borrow the active row's seed.
|
||||
let unrelated = RowAddress {
|
||||
trace: Id::new_v4(),
|
||||
..actual
|
||||
};
|
||||
assert_eq!(
|
||||
review_identity(review, unrelated, destination),
|
||||
format!("Missing identity {destination}")
|
||||
);
|
||||
}
|
||||
assert_eq!(p, accepted);
|
||||
assert!(!p.contains(destination));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_owner_display_does_not_reinterpret_uuid_shaped_artifact_text() {
|
||||
let (p, row, id, _, _) = fixture();
|
||||
let value = json!({"Value": row.target.to_string()});
|
||||
assert_eq!(
|
||||
describe(&p, &artifacts::slot(id, ArtifactFacet::Uri), &value),
|
||||
json!(row.target.to_string()).to_string()
|
||||
);
|
||||
assert!(
|
||||
describe(&p, &artifacts::slot(id, ArtifactFacet::Owner), &value)
|
||||
.contains("owner Monitor equipment")
|
||||
);
|
||||
assert_eq!(
|
||||
describe(
|
||||
&p,
|
||||
&artifacts::slot(id, ArtifactFacet::Owner),
|
||||
&json!("NotApplicable")
|
||||
),
|
||||
"artifact absent"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(
|
||||
&p,
|
||||
&artifacts::slot(id, ArtifactFacet::Revision),
|
||||
&json!({"Value": null})
|
||||
),
|
||||
"null"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(
|
||||
&p,
|
||||
&artifacts::slot(id, ArtifactFacet::Revision),
|
||||
&json!({"Value": ""})
|
||||
),
|
||||
"\"\""
|
||||
);
|
||||
}
|
||||
@@ -165,9 +165,9 @@ fn data_group_candidates_cover_every_definition_and_companion_default_both_direc
|
||||
let paths = constructors::roots(app.project(), target, &group).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(paths[0].clone(), json!(definition)),
|
||||
RowEvent::Variant(paths[0].clone(), json!(definition)),
|
||||
);
|
||||
send(&mut app, Event::Shape(paths[1].clone(), json!(default)));
|
||||
send(&mut app, RowEvent::Shape(paths[1].clone(), json!(default)));
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{definition:?}: {}",
|
||||
@@ -293,9 +293,9 @@ fn group_take_reference_payload_and_scalar_candidate_match_core_and_one_document
|
||||
assert!(!state(&app).can_apply());
|
||||
let oa_choice = view::choice(app.project(), refs[0]);
|
||||
let sa_choice = view::choice(app.project(), refs[1]);
|
||||
send(&mut app, Event::ReferenceOa(oa_choice));
|
||||
send(&mut app, Event::ReferenceSa(sa_choice));
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::ReferenceOa(oa_choice));
|
||||
send(&mut app, RowEvent::ReferenceSa(sa_choice));
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{family} reverse={reverse}: {}",
|
||||
@@ -309,7 +309,7 @@ fn group_take_reference_payload_and_scalar_candidate_match_core_and_one_document
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/description".into(), source_ref.to_string()),
|
||||
RowEvent::Input("/description".into(), source_ref.to_string()),
|
||||
);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
@@ -377,14 +377,17 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
let roots = constructors::roots(app.project(), sa, &group).unwrap();
|
||||
let min_path = format!("{}/Integer/minimum", roots[0]);
|
||||
decide(&mut app, minimum.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Input(min_path.clone(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(min_path.clone(), "-".into()));
|
||||
decide(&mut app, minimum.clone(), SemanticDecision::KeepTarget);
|
||||
decide(&mut app, group.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Input(min_path.clone(), "-draft".into()));
|
||||
send(&mut app, RowEvent::Input(min_path.clone(), "-draft".into()));
|
||||
let cursor = state(&app).group_drafts[&group].inputs[&min_path]
|
||||
.editor
|
||||
.cursor();
|
||||
send(&mut app, Event::Variant(roots[0].clone(), json!("Boolean")));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Variant(roots[0].clone(), json!("Boolean")),
|
||||
);
|
||||
assert_eq!(
|
||||
state(&app).group_drafts[&group].value.pointer(&roots[1]),
|
||||
Some(&json!({"Integer":5}))
|
||||
@@ -392,7 +395,7 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
assert!(!state(&app).can_apply()); // overlap and explicitly incompatible default retained
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
@@ -411,16 +414,19 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
state(&app).draft.as_ref().unwrap().inputs[&min_path].raw,
|
||||
"-"
|
||||
);
|
||||
send(&mut app, Event::Shape(min_path.clone(), Value::Null));
|
||||
send(&mut app, Event::Variant(roots[0].clone(), json!("Boolean")));
|
||||
send(&mut app, RowEvent::Shape(min_path.clone(), Value::Null));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape(roots[1].clone(), json!({"Boolean":true})),
|
||||
RowEvent::Variant(roots[0].clone(), json!("Boolean")),
|
||||
);
|
||||
send(&mut app, Event::Decide(minimum, None));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Shape(roots[1].clone(), json!({"Boolean":true})),
|
||||
);
|
||||
send(&mut app, RowEvent::Decide(minimum, None));
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
for protected in ["/id", "/alias", "/body", "/parent", "/name"] {
|
||||
send(&mut app, Event::Shape(protected.into(), Value::Null));
|
||||
send(&mut app, RowEvent::Shape(protected.into(), Value::Null));
|
||||
assert!(
|
||||
state(&app)
|
||||
.error
|
||||
@@ -433,7 +439,7 @@ fn data_group_retains_invalid_sibling_drafts_companion_default_and_variant_edito
|
||||
state(&app).draft.as_ref().unwrap().inputs[&min_path].raw,
|
||||
"-"
|
||||
);
|
||||
send(&mut app, Event::Preview);
|
||||
send(&mut app, RowEvent::Preview);
|
||||
assert!(state(&app).can_apply());
|
||||
}
|
||||
|
||||
@@ -595,7 +601,7 @@ fn occurrence_and_event_candidate_variants_cover_closed_families_without_touchin
|
||||
SharedBody::Event { body } => json!(body),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
send(&mut app, Event::Shape(root, payload));
|
||||
send(&mut app, RowEvent::Shape(root, payload));
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{body:?}: {}",
|
||||
@@ -636,18 +642,18 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
let roots = constructors::roots(app.project(), sa, &group).unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape(format!("{}/interface", roots[1]), json!(interface)),
|
||||
RowEvent::Shape(format!("{}/interface", roots[1]), json!(interface)),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Append(
|
||||
RowEvent::Append(
|
||||
format!("{}/FunctionInput/incoming_items", roots[0]),
|
||||
json!(item),
|
||||
),
|
||||
@@ -655,7 +661,7 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||
),
|
||||
@@ -678,11 +684,14 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
FacetSelector::Header(HeaderFacet::Parent),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Shape("/parent".into(), json!(component)));
|
||||
send(&mut app, Event::Select(group.clone()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Shape("/parent".into(), json!(component)),
|
||||
);
|
||||
send(&mut app, RowEvent::Select(group.clone()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Variant(
|
||||
roots[0].clone(),
|
||||
json!({"Component":{"kind":"Flow","orientation":"InOut"}}),
|
||||
),
|
||||
@@ -693,7 +702,7 @@ fn port_group_companions_typed_owner_context_and_direction_restrictions_are_expl
|
||||
state(&app).group_drafts[&group].context_value()["parent"],
|
||||
json!(component)
|
||||
);
|
||||
send(&mut app, Event::Shape(roots[1].clone(), Value::Null));
|
||||
send(&mut app, RowEvent::Shape(roots[1].clone(), Value::Null));
|
||||
assert!(state(&app).can_apply());
|
||||
apply(&mut app);
|
||||
assert!(matches!(
|
||||
@@ -736,29 +745,29 @@ fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retai
|
||||
decide(&mut app, nested.clone(), SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "nested draft".into()),
|
||||
RowEvent::Input(expression.clone(), "nested draft".into()),
|
||||
);
|
||||
decide(&mut app, outer.clone(), SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "outer draft".into()),
|
||||
RowEvent::Input(expression.clone(), "outer draft".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(failure(state(&app)).contains("different final values"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
path.clone(),
|
||||
json!({"Action":{"action":null,"lifeline":null}}),
|
||||
),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert_eq!(state(&app).decisions.len(), 2);
|
||||
send(&mut app, Event::Decide(nested.clone(), None));
|
||||
send(&mut app, RowEvent::Decide(nested.clone(), None));
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(
|
||||
RowEvent::Variant(
|
||||
path.clone(),
|
||||
json!({"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}),
|
||||
),
|
||||
@@ -777,7 +786,7 @@ fn outer_and_nested_constructor_candidates_require_explicit_resolution_and_retai
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(path, json!({"Action":{"action":null,"lifeline":null}})),
|
||||
RowEvent::Variant(path, json!({"Action":{"action":null,"lifeline":null}})),
|
||||
);
|
||||
assert!(state(&app).can_apply());
|
||||
let scope = reviewed(state(&app));
|
||||
@@ -841,7 +850,10 @@ fn historical_event_keep_retires_only_obsolete_branch_and_unchanged_kind_acknowl
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input("/name".into(), "Kind only".into()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Input("/name".into(), "Kind only".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
assert_eq!(
|
||||
reviewed(state(&app)),
|
||||
@@ -950,7 +962,7 @@ fn existing_fragment_and_branch_group_candidates_edit_complete_owned_order_witho
|
||||
decide(&mut app, group, SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Array(format!("{root}{suffix}"), Some(1), -1),
|
||||
RowEvent::Array(format!("{root}{suffix}"), Some(1), -1),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
let result = state(&app).proposed.as_ref().unwrap();
|
||||
@@ -1028,7 +1040,7 @@ fn scenario_use_group_takes_typed_binding_references_and_preserves_source_text_b
|
||||
}
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
Some(SemanticDecision::TakeSource),
|
||||
),
|
||||
@@ -1040,18 +1052,18 @@ fn scenario_use_group_takes_typed_binding_references_and_preserves_source_text_b
|
||||
.clone();
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Input("/description".into(), refs[side][1].to_string()),
|
||||
RowEvent::Input("/description".into(), refs[side][1].to_string()),
|
||||
);
|
||||
assert!(s.can_apply(), "{}", failure(&s));
|
||||
assert_eq!(
|
||||
@@ -1101,7 +1113,7 @@ fn reverse_existing_typed_port_reviews_keep_or_candidate_legacy_without_permitti
|
||||
if decision == SemanticDecision::Candidate {
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape("/body/Oa/Shared/Port/legacy/direction".into(), json!("In")),
|
||||
RowEvent::Shape("/body/Oa/Shared/Port/legacy/direction".into(), json!("In")),
|
||||
);
|
||||
if let SharedBody::Port {
|
||||
legacy: Some(association),
|
||||
@@ -1174,7 +1186,7 @@ fn historical_same_kind_take_preserves_target_only_literals_when_composed_with_c
|
||||
);
|
||||
let unit =
|
||||
scalar::binding(app.project(), target, &FacetSelector::Data(DataFacet::Unit)).unwrap();
|
||||
send(&mut app, Event::Input(unit, "final unit".into()));
|
||||
send(&mut app, RowEvent::Input(unit, "final unit".into()));
|
||||
assert!(state(&app).can_apply(), "{}", failure(state(&app)));
|
||||
let SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
@@ -1250,7 +1262,7 @@ fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_an
|
||||
let mut s = State::new(&p, source).unwrap();
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(group.clone(), Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(group.clone(), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(
|
||||
s.can_apply(),
|
||||
@@ -1264,18 +1276,18 @@ fn closed_data_and_event_take_composition_matches_core_across_every_kind_pair_an
|
||||
let expected_review = s.plan.as_ref().unwrap().reviews[0].clone();
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Input("/description".into(), "composed candidate".into()),
|
||||
RowEvent::Input("/description".into(), "composed candidate".into()),
|
||||
);
|
||||
assert!(
|
||||
s.can_apply(),
|
||||
@@ -1417,7 +1429,7 @@ fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_e
|
||||
}
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor),
|
||||
Some(SemanticDecision::TakeSource),
|
||||
),
|
||||
@@ -1425,18 +1437,18 @@ fn exchange_constructor_takes_qualified_annotation_addresses_without_remapping_e
|
||||
assert!(s.can_apply(), "reverse={reverse}: {}", failure(&s));
|
||||
s.update(
|
||||
&p,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Header(HeaderFacet::Description),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
RowEvent::Select(FacetSelector::Header(HeaderFacet::Description)),
|
||||
);
|
||||
s.update(
|
||||
&p,
|
||||
Event::Input("/description".into(), "annotation reviewed".into()),
|
||||
RowEvent::Input("/description".into(), "annotation reviewed".into()),
|
||||
);
|
||||
assert!(s.can_apply(), "reverse={reverse}: {}", failure(&s));
|
||||
let Some(SharedBody::Occurrence {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resettable(facet: &FacetSelector) -> bool {
|
||||
!matches!(facet, FacetSelector::Artifact { .. })
|
||||
pub(super) fn resettable(_facet: &FacetSelector) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn effective_trace(&self, project: &Project) -> Option<Trace> {
|
||||
let mut trace = project.traces.get(&self.trace?)?.clone();
|
||||
if let Some(baseline) = trace.baseline.as_mut().and_then(|b| b.semantic.as_mut()) {
|
||||
@@ -31,7 +31,7 @@ impl State {
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == facet));
|
||||
if !exists || !resettable(&facet) {
|
||||
return Err("Choose an exact existing non-artifact observation to reset.".into());
|
||||
return Err("Choose an exact existing observation to reset.".into());
|
||||
}
|
||||
if reset {
|
||||
self.resets.insert(facet);
|
||||
@@ -41,3 +41,55 @@ impl State {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Only persisted typed addresses establish correspondence. Order vectors and
|
||||
// arbitrary scalar values cannot be zipped or searched for UUID-shaped prose.
|
||||
pub(super) fn reviewed_pairs(
|
||||
row: &ReviewRow,
|
||||
project: &Project,
|
||||
) -> Result<BTreeMap<Id, Id>, String> {
|
||||
let trace = row
|
||||
.effective_trace(project)
|
||||
.ok_or("Choose an existing trace history.")?;
|
||||
let mut result = BTreeMap::new();
|
||||
for fact in trace
|
||||
.baseline
|
||||
.iter()
|
||||
.flat_map(|b| b.semantic.iter())
|
||||
.flat_map(|b| &b.facets)
|
||||
{
|
||||
let mut pairs = vec![];
|
||||
match fact.selector() {
|
||||
FacetSelector::Member { keys, .. }
|
||||
| FacetSelector::Binding(keys)
|
||||
| FacetSelector::Child(keys)
|
||||
| FacetSelector::Incident { keys, .. }
|
||||
| FacetSelector::Artifact { keys, .. } => {
|
||||
if let (Some(oa), Some(sa)) = (keys.oa, keys.sa) {
|
||||
pairs.push((oa, sa));
|
||||
}
|
||||
}
|
||||
FacetSelector::Annotation(KeyPair {
|
||||
oa: Some(oa),
|
||||
sa: Some(sa),
|
||||
}) => {
|
||||
if oa.path.len() != sa.path.len() {
|
||||
return Err("Historical annotation addresses have different depths; restore their correspondence explicitly.".into());
|
||||
}
|
||||
pairs.extend(
|
||||
oa.path
|
||||
.into_iter()
|
||||
.chain([oa.field])
|
||||
.zip(sa.path.into_iter().chain([sa.field])),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for (oa, sa) in pairs {
|
||||
if result.insert(oa, sa).is_some_and(|old| old != sa) {
|
||||
return Err("Historical observations pair one Operational identity with different System identities. Resolve exact history before restoring pairs.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ impl std::fmt::Display for Family {
|
||||
Self::Member(ReferenceCollection::FunctionInputItems) => "Function input items",
|
||||
Self::Member(ReferenceCollection::FunctionOutputItems) => "Function output items",
|
||||
Self::Member(ReferenceCollection::SignalItems) => "Signal items",
|
||||
Self::Member(ReferenceCollection::CategoryExchanges) => "Category exchanges",
|
||||
Self::Member(ReferenceCollection::ExchangeItems) => "Exchange items",
|
||||
Self::Member(ReferenceCollection::TransitionTriggers) => "Transition triggers",
|
||||
Self::Member(ReferenceCollection::TransitionEffects) => "Transition effects",
|
||||
@@ -26,13 +27,14 @@ impl std::fmt::Display for Family {
|
||||
})
|
||||
}
|
||||
}
|
||||
pub(super) const FAMILIES: [Family; 12] = [
|
||||
pub(super) const FAMILIES: [Family; 13] = [
|
||||
Family::Member(ReferenceCollection::Steps),
|
||||
Family::Member(ReferenceCollection::FragmentBranches),
|
||||
Family::Member(ReferenceCollection::FunctionInputItems),
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems),
|
||||
Family::Member(ReferenceCollection::SignalItems),
|
||||
Family::Member(ReferenceCollection::ExchangeItems),
|
||||
Family::Member(ReferenceCollection::CategoryExchanges),
|
||||
Family::Member(ReferenceCollection::TransitionTriggers),
|
||||
Family::Member(ReferenceCollection::TransitionEffects),
|
||||
Family::Member(ReferenceCollection::Constrained),
|
||||
@@ -81,16 +83,22 @@ pub(super) fn path(value: &Value, family: Family) -> Option<String> {
|
||||
Family::Member(ReferenceCollection::FunctionOutputItems) => {
|
||||
&["Port/contract/FunctionOutput/outgoing_items"]
|
||||
}
|
||||
Family::Member(ReferenceCollection::CategoryExchanges) => &["ExchangeCategory/exchanges"],
|
||||
Family::Member(ReferenceCollection::SignalItems) => &["Event/body/Signal/items"],
|
||||
Family::Member(ReferenceCollection::Constrained) => &["Constraint/constrained"],
|
||||
Family::Literal => &["DataType/definition/Enumeration/literals"],
|
||||
Family::Annotation => &["Occurrence/body/Exchange/values"],
|
||||
Family::Binding => &["Occurrence/body/ScenarioUse/bindings"],
|
||||
Family::Member(ReferenceCollection::ExchangeItems) => {
|
||||
return ["Interaction", "FunctionalExchange", "BoundaryExchange"]
|
||||
.iter()
|
||||
.map(|kind| format!("/body/{kind}/items"))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||
return [
|
||||
"Interaction",
|
||||
"FunctionalExchange",
|
||||
"BoundaryExchange",
|
||||
"CommunicationMean",
|
||||
]
|
||||
.iter()
|
||||
.map(|kind| format!("/body/{kind}/items"))
|
||||
.find(|p| value.pointer(p).is_some_and(Value::is_array));
|
||||
}
|
||||
Family::Member(ReferenceCollection::TransitionTriggers) => {
|
||||
return value
|
||||
@@ -164,7 +172,7 @@ pub(super) fn project_presence(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn keyed_value(
|
||||
&self,
|
||||
project: &Project,
|
||||
|
||||
@@ -37,7 +37,7 @@ pub(super) fn target_selector(facet: &FacetSelector, layer: Layer) -> FacetSelec
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn annotation_address(&self, layer: Layer) -> &AddressDraft {
|
||||
if layer == Layer::Oa {
|
||||
&self.annotation_oa
|
||||
|
||||
@@ -196,7 +196,7 @@ fn ready(f: &Fixture, reverse: bool) -> State {
|
||||
let mut s = State::new(&f.p, f.owners[usize::from(reverse)]).unwrap();
|
||||
s.stage_row_references(&f.facet).unwrap();
|
||||
s.references.insert(f.locals[0], f.locals[1]);
|
||||
s.update(&f.p, Event::Select(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::Select(f.facet.clone()));
|
||||
s
|
||||
}
|
||||
|
||||
@@ -216,27 +216,27 @@ fn independent_rows_keep_take_candidate_match_core_and_preserve_siblings_both_di
|
||||
&capture_facet(&f.p, f.owners[0], f.owners[1], &f.sibling).unwrap(),
|
||||
layer,
|
||||
);
|
||||
s.update(&f.p, Event::Decide(f.facet.clone(), Some(decision)));
|
||||
s.update(&f.p, RowEvent::Decide(f.facet.clone(), Some(decision)));
|
||||
if decision == SemanticDecision::Candidate {
|
||||
s.update(&f.p, Event::Presence(f.facet.clone(), !binding));
|
||||
s.update(&f.p, RowEvent::Presence(f.facet.clone(), !binding));
|
||||
if !binding {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
RowEvent::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Force the GUI to compose the row alongside another candidate,
|
||||
// so Take/Keep also exercise adapter scope and mapped references.
|
||||
let header = FacetSelector::Header(HeaderFacet::Description);
|
||||
s.update(&f.p, Event::Select(header.clone()));
|
||||
s.update(&f.p, RowEvent::Select(header.clone()));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(header, Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(header, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Reviewed one row".into()),
|
||||
RowEvent::Input("/description".into(), "Reviewed one row".into()),
|
||||
);
|
||||
assert!(
|
||||
s.can_apply(),
|
||||
@@ -283,29 +283,29 @@ fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_st
|
||||
s.staged.insert(f.facet.clone());
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if binding {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
RowEvent::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
);
|
||||
} else {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.facet.clone(), "unfinished (\n literal UUID text".into()),
|
||||
RowEvent::RowExpression(f.facet.clone(), "unfinished (\n literal UUID text".into()),
|
||||
);
|
||||
}
|
||||
s.update(&f.p, Event::Presence(f.facet.clone(), false));
|
||||
s.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
s.update(&f.p, Event::DropStaged(f.facet.clone()));
|
||||
s.update(&f.p, Event::Select(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::DropStaged(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::Select(f.facet.clone()));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert!(!s.presence[&f.facet]);
|
||||
if binding {
|
||||
@@ -323,7 +323,7 @@ fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_st
|
||||
// A forged event for an unselected sibling cannot alter its row draft.
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.sibling.clone(), "wrong row".into()),
|
||||
RowEvent::RowExpression(f.sibling.clone(), "wrong row".into()),
|
||||
);
|
||||
assert!(s.error.as_ref().unwrap().contains("Select this row"));
|
||||
}
|
||||
@@ -333,21 +333,24 @@ fn keyed_row_drafts_retain_expression_local_and_presence_across_decisions_and_st
|
||||
fn annotation_address_browser_descends_qualified_fields_without_changing_rows() {
|
||||
let f = fixture(false);
|
||||
let mut s = ready(&f, false);
|
||||
s.update(&f.p, Event::MemberFamily(keyed::Family::Annotation));
|
||||
s.update(&f.p, RowEvent::MemberFamily(keyed::Family::Annotation));
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let roots = s.annotation_choices(&f.p, layer).unwrap();
|
||||
assert_eq!(roots.len(), 2);
|
||||
let key = roots[0].key.clone();
|
||||
s.update(&f.p, Event::AnnotationBrowse(layer, Some(key.clone()), 0));
|
||||
s.update(
|
||||
&f.p,
|
||||
RowEvent::AnnotationBrowse(layer, Some(key.clone()), 0),
|
||||
);
|
||||
let nested = s.annotation_choices(&f.p, layer).unwrap();
|
||||
assert_eq!(nested.len(), 1);
|
||||
assert_eq!(nested[0].key.path, vec![key.field]);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::AnnotationAddress(layer, Some(nested[0].key.clone())),
|
||||
RowEvent::AnnotationAddress(layer, Some(nested[0].key.clone())),
|
||||
);
|
||||
}
|
||||
s.update(&f.p, Event::StageMember);
|
||||
s.update(&f.p, RowEvent::StageMember);
|
||||
assert!(s.error.is_none(), "{}", errors(&s));
|
||||
assert_eq!(s.staged.len(), 1);
|
||||
assert!(s.decisions.is_empty());
|
||||
@@ -358,7 +361,7 @@ fn annotation_address_browser_descends_qualified_fields_without_changing_rows()
|
||||
fn binding_choices_separate_reused_keys_from_local_lifelines() {
|
||||
let f = fixture(true);
|
||||
let mut s = ready(&f, false);
|
||||
s.update(&f.p, Event::MemberFamily(keyed::Family::Binding));
|
||||
s.update(&f.p, RowEvent::MemberFamily(keyed::Family::Binding));
|
||||
for (index, layer) in [Layer::Oa, Layer::Sa].into_iter().enumerate() {
|
||||
assert_eq!(s.member_choices(&f.p, layer).len(), 2);
|
||||
assert!(
|
||||
@@ -379,14 +382,14 @@ fn binding_local_picker_follows_a_candidate_parent_before_the_review_is_valid()
|
||||
let local = add(&mut f.p, Kind::Lifeline, Layer::Sa, Some(destination));
|
||||
let mut s = ready(&f, false);
|
||||
let parent = FacetSelector::Header(HeaderFacet::Parent);
|
||||
s.update(&f.p, Event::Select(parent.clone()));
|
||||
s.update(&f.p, RowEvent::Select(parent.clone()));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(parent, Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(parent, Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Shape("/parent".into(), serde_json::json!(destination)),
|
||||
RowEvent::Shape("/parent".into(), serde_json::json!(destination)),
|
||||
);
|
||||
assert_eq!(
|
||||
s.binding_choices(&f.p, Layer::Sa, false)
|
||||
@@ -416,14 +419,14 @@ fn row_review_apply_preserves_exact_source_and_semantic_history_through_undo_red
|
||||
app.document.apply_bundle(bundle).unwrap();
|
||||
let before = app.document.snapshot();
|
||||
start(&mut app, f.owners[0]);
|
||||
send(&mut app, Event::EditPair(f.facet.clone()));
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, Event::Select(f.facet.clone()));
|
||||
send(&mut app, RowEvent::EditPair(f.facet.clone()));
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
send(&mut app, RowEvent::Select(f.facet.clone()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
send(&mut app, Event::Presence(f.facet.clone(), false));
|
||||
send(&mut app, RowEvent::Presence(f.facet.clone(), false));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
let expected = state(&app).proposed.as_ref().unwrap().clone();
|
||||
apply(&mut app);
|
||||
@@ -503,13 +506,13 @@ fn keyed_rows_distinguish_unpaired_source_from_explicit_absence_and_require_exac
|
||||
let mut s = ready(&f, false);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::KeepTarget)),
|
||||
);
|
||||
assert!(
|
||||
!s.can_apply(),
|
||||
"An old one-sided address cannot silently acquire a paired history"
|
||||
);
|
||||
s.update(&f.p, Event::Reset(source_only.clone(), true));
|
||||
s.update(&f.p, RowEvent::Reset(source_only.clone(), true));
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
let next = s.proposed.as_ref().unwrap();
|
||||
let facts = &next.traces[&f.trace]
|
||||
@@ -541,17 +544,17 @@ fn keyed_rows_distinguish_unpaired_source_from_explicit_absence_and_require_exac
|
||||
let mut s = ready(&f, false);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(facet, Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(facet, Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
let header = FacetSelector::Header(HeaderFacet::Description);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(header.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(header.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(&f.p, Event::Select(header));
|
||||
s.update(&f.p, RowEvent::Select(header));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Compose explicit absence".into()),
|
||||
RowEvent::Input("/description".into(), "Compose explicit absence".into()),
|
||||
);
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
let fact = capture_facet(
|
||||
@@ -576,18 +579,18 @@ fn binding_take_requires_local_correspondence_and_candidate_rejects_wrong_scenar
|
||||
s.references.remove(&f.locals[0]);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
assert!(!s.can_apply());
|
||||
s.references.insert(f.locals[0], f.locals[1]);
|
||||
s.update(&f.p, Event::Preview);
|
||||
s.update(&f.p, RowEvent::Preview);
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let wrong = s.binding_choices(&f.p, Layer::Sa, true)[0].clone();
|
||||
s.update(&f.p, Event::RowLocal(f.facet.clone(), wrong));
|
||||
s.update(&f.p, RowEvent::RowLocal(f.facet.clone(), wrong));
|
||||
assert!(!s.can_apply());
|
||||
assert!(
|
||||
s.row_drafts[&f.facet].local.is_some(),
|
||||
@@ -595,7 +598,7 @@ fn binding_take_requires_local_correspondence_and_candidate_rejects_wrong_scenar
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
RowEvent::RowLocal(f.facet.clone(), view::choice(&f.p, f.locals[1])),
|
||||
);
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
}
|
||||
@@ -635,16 +638,19 @@ fn keyed_recovery_retains_raw_rows_and_staged_pairs_both_directions_without_appl
|
||||
s.staged.insert(f.facet.clone());
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if !binding {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(f.facet.clone(), "unfinished (\n exact raw draft".into()),
|
||||
RowEvent::RowExpression(
|
||||
f.facet.clone(),
|
||||
"unfinished (\n exact raw draft".into(),
|
||||
),
|
||||
);
|
||||
}
|
||||
s.update(&f.p, Event::Presence(f.facet.clone(), false));
|
||||
s.update(&f.p, Event::EditPair(f.facet.clone()));
|
||||
s.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
s.update(&f.p, RowEvent::EditPair(f.facet.clone()));
|
||||
let saved = s.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.p, saved).unwrap();
|
||||
assert_eq!(restored.direction, s.direction);
|
||||
@@ -698,23 +704,23 @@ fn multiple_new_rows_compose_in_empty_collections_using_destination_key_order()
|
||||
let object_count = f.p.elements.len();
|
||||
let mut s = ready(&f, reverse);
|
||||
for (facet, row) in facets.iter().zip(&original).rev() {
|
||||
s.update(&f.p, Event::EditPair(facet.clone()));
|
||||
s.update(&f.p, Event::StageMember);
|
||||
s.update(&f.p, RowEvent::EditPair(facet.clone()));
|
||||
s.update(&f.p, RowEvent::StageMember);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
s.update(&f.p, Event::Presence(facet.clone(), true));
|
||||
s.update(&f.p, RowEvent::Presence(facet.clone(), true));
|
||||
if binding {
|
||||
let local = row["local"].as_str().unwrap().parse().unwrap();
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowLocal(facet.clone(), view::choice(&f.p, local)),
|
||||
RowEvent::RowLocal(facet.clone(), view::choice(&f.p, local)),
|
||||
);
|
||||
} else {
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::RowExpression(facet.clone(), "new independent value".into()),
|
||||
RowEvent::RowExpression(facet.clone(), "new independent value".into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -734,3 +740,90 @@ fn multiple_new_rows_compose_in_empty_collections_using_destination_key_order()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_keyed_rows_compare_exact_target_slots_and_retain_independent_values() {
|
||||
for binding in [false, true] {
|
||||
let mut f = fixture(binding);
|
||||
let other = Id::new_v4();
|
||||
let mut source = f.p.elements[&f.owners[0]].clone();
|
||||
source.id = other;
|
||||
source.alias = format!("other_{}", other.simple());
|
||||
source.name = "Alternate operational rationale".into();
|
||||
let parent = source.parent.unwrap();
|
||||
f.p.elements.insert(other, source);
|
||||
f.p.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(other);
|
||||
let second = add_trace(&mut f.p, other, f.owners[1], "Alternate keyed rationale");
|
||||
validate_candidate(&f.p).unwrap();
|
||||
let mut state = ready(&f, false);
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if binding {
|
||||
state.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
} else {
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let first_value = state.proposed.clone().unwrap();
|
||||
state.update(&f.p, Event::ChooseTrace(view::choice(&f.p, second)));
|
||||
state.update(&f.p, Event::OpenTrace);
|
||||
state.update(&f.p, RowEvent::Select(f.facet.clone()));
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::Decide(f.facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
if !binding {
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::RowExpression(f.facet.clone(), "Another value".into()),
|
||||
);
|
||||
}
|
||||
assert!(!state.can_apply());
|
||||
assert!(errors(&state).contains("disagree"), "{}", errors(&state));
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
assert!(!restored.can_apply());
|
||||
if binding {
|
||||
state.update(&f.p, RowEvent::Presence(f.facet.clone(), false));
|
||||
} else {
|
||||
state.update(
|
||||
&f.p,
|
||||
RowEvent::RowExpression(f.facet.clone(), f.locals[0].to_string()),
|
||||
);
|
||||
}
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
assert_eq!(state.plan.as_ref().unwrap().request.candidates.len(), 1);
|
||||
assert_eq!(
|
||||
state.proposed.as_ref().unwrap().elements[&f.owners[1]],
|
||||
first_value.elements[&f.owners[1]]
|
||||
);
|
||||
assert_eq!(
|
||||
scalar::value(
|
||||
&capture_facet(
|
||||
state.proposed.as_ref().unwrap(),
|
||||
f.owners[0],
|
||||
f.owners[1],
|
||||
&f.sibling
|
||||
)
|
||||
.unwrap(),
|
||||
Layer::Sa
|
||||
),
|
||||
scalar::value(
|
||||
&capture_facet(&f.p, f.owners[0], f.owners[1], &f.sibling).unwrap(),
|
||||
Layer::Sa
|
||||
)
|
||||
);
|
||||
let _ = state.view(&f.p);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,8 +194,8 @@ fn member_pair(collection: ReferenceCollection) -> (Project, Id, Id, Id, FacetSe
|
||||
)
|
||||
}
|
||||
fn stage(app: &mut App, facet: &FacetSelector) {
|
||||
send(app, Event::EditPair(facet.clone()));
|
||||
send(app, Event::StageMember);
|
||||
send(app, RowEvent::EditPair(facet.clone()));
|
||||
send(app, RowEvent::StageMember);
|
||||
assert_eq!(
|
||||
state(app).selected.as_ref(),
|
||||
Some(facet),
|
||||
@@ -240,7 +240,7 @@ fn unordered_members_keep_take_candidate_preserve_other_rows_both_directions() {
|
||||
stage(&mut app, &facet);
|
||||
decide(&mut app, facet.clone(), decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
send(&mut app, Event::Presence(facet.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(facet.clone(), true));
|
||||
}
|
||||
// A target-only Take is an explicit acknowledgment, never implicit removal.
|
||||
decide(&mut app, local_facet.clone(), SemanticDecision::TakeSource);
|
||||
@@ -295,16 +295,16 @@ fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() {
|
||||
state(&app).member_families(app.project()),
|
||||
vec![keyed::Family::Member(collection)]
|
||||
);
|
||||
send(&mut app, Event::EditPair(facet.clone()));
|
||||
send(&mut app, RowEvent::EditPair(facet.clone()));
|
||||
assert!(
|
||||
state(&app)
|
||||
.member_choices(app.project(), Layer::Sa)
|
||||
.iter()
|
||||
.any(|c| Some(c.id) == keys.sa)
|
||||
);
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(facet.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(facet.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
apply(&mut app);
|
||||
assert!(contains(app.project(), sa, &facet, oa, sa));
|
||||
@@ -320,7 +320,7 @@ fn function_port_members_use_existing_typed_choices_and_preserve_oa_legacy() {
|
||||
decide(&mut app, facet.clone(), SemanticDecision::KeepTarget);
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(facet, true));
|
||||
send(&mut app, RowEvent::Presence(facet, true));
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
"OA typed membership must remain inapplicable"
|
||||
@@ -348,7 +348,10 @@ fn exact_literal_new_rows_and_removals_commit_both_directions_without_default_lo
|
||||
stage(&mut app, facet);
|
||||
decide(&mut app, facet.clone(), decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
send(&mut app, Event::Presence(facet.clone(), facet == &created));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Presence(facet.clone(), facet == &created),
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
@@ -387,7 +390,7 @@ fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directi
|
||||
let before = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
start(&mut app, if reverse { sa } else { oa });
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
assert!(state(&app).decisions.is_empty());
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
assert_eq!(
|
||||
@@ -399,7 +402,7 @@ fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directi
|
||||
.drift,
|
||||
Drift::Unreviewed
|
||||
);
|
||||
send(&mut app, Event::Reset(reset.clone(), false));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), false));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert_eq!(
|
||||
state(&app)
|
||||
@@ -410,7 +413,7 @@ fn exact_reset_only_is_one_history_source_transaction_and_undo_redo_both_directi
|
||||
.drift,
|
||||
Drift::Unchanged
|
||||
);
|
||||
send(&mut app, Event::Reset(reset, true));
|
||||
send(&mut app, RowEvent::Reset(reset, true));
|
||||
apply(&mut app);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert_eq!(app.project().elements, before.decoded.project.elements);
|
||||
@@ -462,17 +465,17 @@ fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap
|
||||
decide(&mut app, previous.clone(), SemanticDecision::KeepTarget);
|
||||
stage(&mut app, &paired);
|
||||
decide(&mut app, paired.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(paired.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(paired.clone(), true));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert_eq!(state(&app).decisions.len(), 2);
|
||||
send(&mut app, Event::Decide(previous.clone(), None));
|
||||
send(&mut app, RowEvent::Decide(previous.clone(), None));
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
"Historic pair cannot silently migrate"
|
||||
);
|
||||
send(&mut app, Event::Reset(previous.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(previous.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
send(&mut app, Event::Reset(previous, false));
|
||||
send(&mut app, RowEvent::Reset(previous, false));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(state(&app).presence[&paired]);
|
||||
let old = state(&app).resets.clone();
|
||||
@@ -486,7 +489,7 @@ fn historical_one_sided_pair_migration_requires_exact_reset_and_explicit_overlap
|
||||
.unwrap()
|
||||
.facets[0]
|
||||
.selector();
|
||||
send(&mut app, Event::Reset(historic, true));
|
||||
send(&mut app, RowEvent::Reset(historic, true));
|
||||
apply(&mut app);
|
||||
assert!(contains(app.project(), target, &paired, oa, sa));
|
||||
assert_eq!(
|
||||
@@ -515,8 +518,8 @@ fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_
|
||||
let created = literal("separate");
|
||||
stage(&mut app, &created);
|
||||
decide(&mut app, created.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(created.clone(), true));
|
||||
send(&mut app, Event::Reset(ctor.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(created.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(ctor.clone(), true));
|
||||
decide(&mut app, ctor.clone(), SemanticDecision::Candidate);
|
||||
assert!(state(&app).can_acknowledge_constructor(app.project(), &ctor));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
@@ -530,10 +533,10 @@ fn reset_precedes_equal_constructor_candidate_and_preserves_independent_literal_
|
||||
BTreeSet::from([ctor.clone(), created.clone()])
|
||||
);
|
||||
assert!(reviewed.retired.is_empty());
|
||||
send(&mut app, Event::Reset(ctor.clone(), false));
|
||||
send(&mut app, RowEvent::Reset(ctor.clone(), false));
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(state(&app).presence[&created]);
|
||||
send(&mut app, Event::Reset(ctor, true));
|
||||
send(&mut app, RowEvent::Reset(ctor, true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
}
|
||||
}
|
||||
@@ -547,7 +550,7 @@ fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repair
|
||||
decide(&mut app, unit.clone(), SemanticDecision::Candidate);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
RowEvent::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
" untouched unit ".into(),
|
||||
),
|
||||
@@ -555,13 +558,13 @@ fn invalid_literal_removal_and_unselected_scalar_buffers_are_retained_and_repair
|
||||
decide(&mut app, unit.clone(), SemanticDecision::KeepTarget);
|
||||
let retained = literal("retained");
|
||||
decide(&mut app, retained.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(retained.clone(), false));
|
||||
send(&mut app, RowEvent::Presence(retained.clone(), false));
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
"Existing default must prevent removal"
|
||||
);
|
||||
assert!(!state(&app).presence[&retained]);
|
||||
send(&mut app, Event::Presence(retained.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(retained.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs["/body/Sa/Shared/DataType/unit"].raw,
|
||||
@@ -585,15 +588,15 @@ fn unsupported_independent_families_and_missing_identities_do_not_gain_edit_auth
|
||||
};
|
||||
decide(&mut app, unsupported.clone(), SemanticDecision::Candidate);
|
||||
assert!(!state(&app).decisions.contains_key(&unsupported));
|
||||
send(&mut app, Event::MemberFamily(keyed::Family::Literal));
|
||||
send(&mut app, Event::Literal("unpaired".into()));
|
||||
send(&mut app, Event::LiteralSide(Layer::Sa, false));
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, RowEvent::MemberFamily(keyed::Family::Literal));
|
||||
send(&mut app, RowEvent::Literal("unpaired".into()));
|
||||
send(&mut app, RowEvent::LiteralSide(Layer::Sa, false));
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
let facet = state(&app).selected.clone().unwrap();
|
||||
decide(&mut app, facet.clone(), SemanticDecision::Candidate);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::LiteralSide(Layer::Oa, false));
|
||||
send(&mut app, Event::StageMember);
|
||||
send(&mut app, RowEvent::LiteralSide(Layer::Oa, false));
|
||||
send(&mut app, RowEvent::StageMember);
|
||||
assert!(state(&app).error.is_some());
|
||||
assert_eq!(
|
||||
state(&app).decisions.get(&facet),
|
||||
@@ -654,7 +657,7 @@ fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() {
|
||||
history(&mut p, trace, std::slice::from_ref(&reset));
|
||||
let mut app = App::with_project(p.clone());
|
||||
start(&mut app, oa);
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
app.document
|
||||
.execute(Command::Rename(oa, "Changed after preview".into()))
|
||||
.unwrap();
|
||||
@@ -669,11 +672,11 @@ fn stale_or_source_failed_reset_and_membership_apply_retain_exact_choices() {
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 10;
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
start(&mut app, oa);
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
let added = literal(&" exact long literal ".repeat(100));
|
||||
stage(&mut app, &added);
|
||||
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(added.clone(), true));
|
||||
send(&mut app, RowEvent::Presence(added.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
let before = app.document.snapshot();
|
||||
apply(&mut app);
|
||||
@@ -729,10 +732,10 @@ fn member_identity_labels_and_new_tabs_preserve_exact_names_and_deleted_keys() {
|
||||
let mut app = App::with_project(p);
|
||||
start(&mut app, oa);
|
||||
for tab in [Tab::History, Tab::Members, Tab::Facts] {
|
||||
send(&mut app, Event::Tab(tab));
|
||||
send(&mut app, RowEvent::Tab(tab));
|
||||
let _ = state(&app).view(app.project());
|
||||
}
|
||||
send(&mut app, Event::Reset(old1.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(old1.clone(), true));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
assert!(!state(&app).facts.iter().any(|f| f.selector == old1));
|
||||
assert!(state(&app).facts.iter().any(|f| f.selector == old2));
|
||||
@@ -745,12 +748,12 @@ fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_pres
|
||||
history(&mut p, trace, std::slice::from_ref(&reset));
|
||||
let mut app = App::with_project(p.clone());
|
||||
start(&mut app, oa);
|
||||
send(&mut app, Event::Reset(reset.clone(), true));
|
||||
send(&mut app, RowEvent::Reset(reset.clone(), true));
|
||||
let added = literal("staged draft");
|
||||
stage(&mut app, &added);
|
||||
decide(&mut app, added.clone(), SemanticDecision::Candidate);
|
||||
send(&mut app, Event::Presence(added.clone(), true));
|
||||
send(&mut app, Event::DropStaged(added.clone()));
|
||||
send(&mut app, RowEvent::Presence(added.clone(), true));
|
||||
send(&mut app, RowEvent::DropStaged(added.clone()));
|
||||
assert!(state(&app).decisions.is_empty());
|
||||
assert!(!state(&app).staged.contains(&added));
|
||||
assert!(state(&app).presence[&added]);
|
||||
@@ -776,7 +779,14 @@ fn staging_removal_and_obsolete_reset_recovery_keep_other_decisions_and_raw_pres
|
||||
assert!(!s.can_apply());
|
||||
s.tab = Tab::History;
|
||||
let _ = s.view(&p);
|
||||
s.update(&p, Event::Reset(reset, false));
|
||||
assert!(s.can_apply(), "{}", errors(s));
|
||||
s.update(&p, RowEvent::Reset(reset.clone(), false));
|
||||
// The old single-row test implicitly adopted externally changed history on
|
||||
// refresh. A batch has one frozen accepted base: withdrawing raw reset intent
|
||||
// survives, while application requires an explicitly reopened review.
|
||||
assert!(!s.resets.contains(&reset));
|
||||
assert!(!s.can_apply());
|
||||
assert!(s.error.as_ref().unwrap().contains("stale"));
|
||||
assert!(s.plan.is_none() && s.proposed.is_none());
|
||||
assert_eq!(s.decisions.get(&added), Some(&SemanticDecision::Candidate));
|
||||
assert!(s.presence[&added]);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ fn message_group_take_full_timer_and_scalar_candidate_are_one_source_history_cha
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/name".into(), "Reviewed message".into()),
|
||||
RowEvent::Input("/name".into(), "Reviewed message".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
@@ -189,7 +189,7 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "independent scalar timer".into()),
|
||||
RowEvent::Input(expression.clone(), "independent scalar timer".into()),
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
@@ -200,11 +200,11 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
assert!(!state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), " group β timer\n\t ".into()),
|
||||
RowEvent::Input(expression.clone(), " group β timer\n\t ".into()),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Text(expression.clone(), text_editor::Action::SelectAll),
|
||||
RowEvent::Text(expression.clone(), text_editor::Action::SelectAll),
|
||||
);
|
||||
let cursor = state(&app)
|
||||
.group_drafts
|
||||
@@ -222,11 +222,11 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
.selection();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(kind.clone(), json!("Asynchronous")),
|
||||
RowEvent::Variant(kind.clone(), json!("Asynchronous")),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(kind, json!({"Timer":{"expression":""}})),
|
||||
RowEvent::Variant(kind, json!({"Timer":{"expression":""}})),
|
||||
);
|
||||
assert_eq!(
|
||||
state(&app)
|
||||
@@ -265,11 +265,11 @@ fn message_group_candidate_timer_switches_preserve_both_drafts_and_overlap_is_ex
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
// The visible overlap-resolution actions only change the explicitly chosen
|
||||
// decision. Neither independent candidate buffer is discarded.
|
||||
send(&mut app, Event::Decide(message_kind::selector(), None));
|
||||
send(&mut app, RowEvent::Decide(message_kind::selector(), None));
|
||||
assert!(state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(message_kind::timer_selector(), None),
|
||||
RowEvent::Decide(message_kind::timer_selector(), None),
|
||||
);
|
||||
decide(
|
||||
&mut app,
|
||||
@@ -395,7 +395,7 @@ fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_dir
|
||||
message_kind::selector(),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Variant(path(layer), json!("Reply")));
|
||||
send(&mut app, RowEvent::Variant(path(layer), json!("Reply")));
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Occurrence(OccurrenceFacet::ReplyTo),
|
||||
@@ -409,19 +409,19 @@ fn message_group_reply_and_reference_take_require_explicit_call_mapping_both_dir
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceOa(Choice {
|
||||
RowEvent::ReferenceOa(Choice {
|
||||
id: calls[0],
|
||||
label: "OA call".into(),
|
||||
}),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceSa(Choice {
|
||||
RowEvent::ReferenceSa(Choice {
|
||||
id: calls[1],
|
||||
label: "SA call".into(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
assert!(state(&app).can_apply(), "{}", failures(&app));
|
||||
assert_eq!(
|
||||
acknowledged(&app),
|
||||
@@ -480,7 +480,7 @@ fn message_group_candidate_closed_kinds_are_core_validated_in_both_directions()
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(path(layer), serde_json::to_value(&kind).unwrap()),
|
||||
RowEvent::Variant(path(layer), serde_json::to_value(&kind).unwrap()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{kind:?}: {}", failures(&app));
|
||||
assert_eq!(
|
||||
@@ -511,7 +511,7 @@ fn message_group_candidate_closed_kinds_are_core_validated_in_both_directions()
|
||||
);
|
||||
review.update(
|
||||
&p,
|
||||
Event::Decide(message_kind::selector(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(message_kind::selector(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
assert!(review.decisions.is_empty());
|
||||
assert!(!review.can_apply());
|
||||
@@ -552,7 +552,7 @@ fn message_group_timer_does_not_erase_exchange_without_its_own_selected_decision
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(path("Sa"), json!({"Timer":{"expression":"after 2 s"}})),
|
||||
RowEvent::Variant(path("Sa"), json!({"Timer":{"expression":"after 2 s"}})),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
@@ -569,7 +569,7 @@ fn message_group_timer_does_not_erase_exchange_without_its_own_selected_decision
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape(
|
||||
RowEvent::Shape(
|
||||
"/body/Sa/Shared/Occurrence/body/Exchange/exchange".into(),
|
||||
Value::Null,
|
||||
),
|
||||
@@ -606,7 +606,10 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
FacetSelector::Header(HeaderFacet::Name),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input("/name".into(), "Final name".into()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Input("/name".into(), "Final name".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
message_kind::value(state(&app).proposed.as_ref().unwrap(), sa),
|
||||
@@ -621,7 +624,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "authored group timer".into()),
|
||||
RowEvent::Input(expression.clone(), "authored group timer".into()),
|
||||
);
|
||||
assert!(
|
||||
!state(&app).can_apply(),
|
||||
@@ -634,7 +637,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression.clone(), "different scalar timer".into()),
|
||||
RowEvent::Input(expression.clone(), "different scalar timer".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
@@ -646,7 +649,7 @@ fn message_group_initial_equal_take_acknowledges_only_kind_and_checks_authored_t
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(expression, "authored group timer".into()),
|
||||
RowEvent::Input(expression, "authored group timer".into()),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert!(acknowledged(&app).contains(&message_kind::timer_selector()));
|
||||
@@ -676,11 +679,11 @@ fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_l
|
||||
);
|
||||
let expression = format!("{}/Timer/expression", path("Sa"));
|
||||
let raw = "retain exact timer β\n".repeat(40);
|
||||
send(&mut app, Event::Input(expression.clone(), raw.clone()));
|
||||
send(&mut app, RowEvent::Input(expression.clone(), raw.clone()));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/name".into(), "Unselected protected scope".into()),
|
||||
RowEvent::Input("/name".into(), "Unselected protected scope".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
@@ -690,7 +693,7 @@ fn message_group_rejects_protected_edits_stale_plan_and_source_failure_without_l
|
||||
.unwrap()
|
||||
.contains("selected review")
|
||||
);
|
||||
send(&mut app, Event::Preview);
|
||||
send(&mut app, RowEvent::Preview);
|
||||
assert!(state(&app).can_apply());
|
||||
if !source_failure {
|
||||
app.document
|
||||
|
||||
@@ -26,7 +26,7 @@ fn write(value: &mut Value, which: OrderedCollection, ids: Vec<Id>) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub(super) fn ordered_members(
|
||||
&self,
|
||||
project: &Project,
|
||||
|
||||
@@ -172,8 +172,8 @@ fn ready(f: &Fixture, reverse: bool) -> State {
|
||||
state
|
||||
}
|
||||
fn decide(state: &mut State, p: &Project, facet: FacetSelector, decision: SemanticDecision) {
|
||||
state.update(p, Event::Select(facet.clone()));
|
||||
state.update(p, Event::Decide(facet, Some(decision)));
|
||||
state.update(p, RowEvent::Select(facet.clone()));
|
||||
state.update(p, RowEvent::Decide(facet, Some(decision)));
|
||||
}
|
||||
fn errors(s: &State) -> String {
|
||||
s.error
|
||||
@@ -231,7 +231,7 @@ fn ordered_take_uses_unique_anchors_and_matches_core_with_unrelated_candidate_bo
|
||||
decide(&mut s, &f.p, header, SemanticDecision::Candidate);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Reviewed startup insertion".into()),
|
||||
RowEvent::Input("/description".into(), "Reviewed startup insertion".into()),
|
||||
);
|
||||
let ti = 1 - usize::from(reverse);
|
||||
let mut expected = f.p.clone();
|
||||
@@ -268,7 +268,7 @@ fn ambiguous_order_preserves_local_member_and_requires_explicit_candidate_positi
|
||||
s.order_drafts[&f.which],
|
||||
vec![f.rows[ti][0], f.rows[ti][3], f.rows[ti][1], f.rows[ti][2]]
|
||||
);
|
||||
s.update(&f.p, Event::OrderMove(f.which, 3, -1));
|
||||
s.update(&f.p, RowEvent::OrderMove(f.which, 3, -1));
|
||||
let mut expected = f.p.clone();
|
||||
set_order(
|
||||
&mut expected,
|
||||
@@ -297,7 +297,7 @@ fn ordered_membership_choices_stage_exact_pairs_in_all_owner_shapes() {
|
||||
let mut s = ready(&f, false);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::MemberFamily(keyed::Family::Member(ordered::collection(f.which))),
|
||||
RowEvent::MemberFamily(keyed::Family::Member(ordered::collection(f.which))),
|
||||
);
|
||||
for (i, layer) in [(0, Layer::Oa), (1, Layer::Sa)] {
|
||||
let choices = s.member_choices(&f.p, layer);
|
||||
@@ -311,13 +311,13 @@ fn ordered_membership_choices_stage_exact_pairs_in_all_owner_shapes() {
|
||||
}
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::MemberOa(Some(view::choice(&f.p, f.rows[0][2]))),
|
||||
RowEvent::MemberOa(Some(view::choice(&f.p, f.rows[0][2]))),
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::MemberSa(Some(view::choice(&f.p, f.rows[1][2]))),
|
||||
RowEvent::MemberSa(Some(view::choice(&f.p, f.rows[1][2]))),
|
||||
);
|
||||
s.update(&f.p, Event::StageMember);
|
||||
s.update(&f.p, RowEvent::StageMember);
|
||||
assert!(s.staged.contains(&member(&f, 2)));
|
||||
assert_eq!(s.references[&f.rows[0][2]], f.rows[1][2]);
|
||||
}
|
||||
@@ -330,20 +330,20 @@ fn raw_order_survives_membership_changes_decisions_and_recovery_until_explicit_r
|
||||
let mut s = ready(&f, reverse);
|
||||
let order = FacetSelector::Order(f.which);
|
||||
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
|
||||
s.update(&f.p, Event::OrderMove(f.which, 1, -1));
|
||||
s.update(&f.p, RowEvent::OrderMove(f.which, 1, -1));
|
||||
let authored = s.order_drafts[&f.which].clone();
|
||||
decide(&mut s, &f.p, member(&f, 2), SemanticDecision::Candidate);
|
||||
s.update(&f.p, Event::Presence(member(&f, 2), true));
|
||||
s.update(&f.p, RowEvent::Presence(member(&f, 2), true));
|
||||
assert!(!s.can_apply());
|
||||
assert_eq!(s.order_drafts[&f.which], authored);
|
||||
s.update(&f.p, Event::Decide(order.clone(), None));
|
||||
s.update(&f.p, RowEvent::Decide(order.clone(), None));
|
||||
decide(&mut s, &f.p, order.clone(), SemanticDecision::Candidate);
|
||||
let saved = s.recovery_snapshot().unwrap();
|
||||
let mut restored = State::from_recovery(&f.p, saved.clone()).unwrap();
|
||||
assert_eq!(restored.order_drafts[&f.which], authored);
|
||||
assert!(!restored.can_apply());
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), saved);
|
||||
restored.update(&f.p, Event::OrderReconcile(f.which));
|
||||
restored.update(&f.p, RowEvent::OrderReconcile(f.which));
|
||||
let ti = 1 - usize::from(reverse);
|
||||
let mut expected = f.p.clone();
|
||||
let mut final_order = authored;
|
||||
@@ -385,7 +385,7 @@ fn order_take_preserves_destination_only_positions_and_order_alone_cannot_add_me
|
||||
s.refresh(&f.p);
|
||||
assert!(!s.can_apply());
|
||||
assert!(errors(&s).contains("surviving"), "{}", errors(&s));
|
||||
s.update(&f.p, Event::OrderReconcile(f.which));
|
||||
s.update(&f.p, RowEvent::OrderReconcile(f.which));
|
||||
assert!(s.can_apply(), "{}", errors(&s));
|
||||
}
|
||||
}
|
||||
@@ -406,28 +406,28 @@ fn ordered_review_apply_is_exact_source_history_transaction_with_reopen_and_undo
|
||||
for i in 0..3 {
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceOa(view::choice(&f.p, f.rows[0][i])),
|
||||
RowEvent::ReferenceOa(view::choice(&f.p, f.rows[0][i])),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceSa(view::choice(&f.p, f.rows[1][i])),
|
||||
RowEvent::ReferenceSa(view::choice(&f.p, f.rows[1][i])),
|
||||
);
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
}
|
||||
send(&mut app, Event::Select(member(&f, 2)));
|
||||
send(&mut app, RowEvent::Select(member(&f, 2)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(member(&f, 2), Some(SemanticDecision::TakeSource)),
|
||||
RowEvent::Decide(member(&f, 2), Some(SemanticDecision::TakeSource)),
|
||||
);
|
||||
send(&mut app, Event::Select(FacetSelector::Order(f.which)));
|
||||
send(&mut app, RowEvent::Select(FacetSelector::Order(f.which)));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Order(f.which),
|
||||
Some(SemanticDecision::Candidate),
|
||||
),
|
||||
);
|
||||
send(&mut app, Event::OrderMove(f.which, 3, -1));
|
||||
send(&mut app, RowEvent::OrderMove(f.which, 3, -1));
|
||||
assert!(state(&app).can_apply(), "{}", errors(state(&app)));
|
||||
let expected = state(&app).proposed.clone().unwrap();
|
||||
apply(&mut app);
|
||||
@@ -479,7 +479,7 @@ fn several_incoming_members_follow_source_order_despite_independent_identity_sor
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Multiple incoming steps".into()),
|
||||
RowEvent::Input("/description".into(), "Multiple incoming steps".into()),
|
||||
);
|
||||
let mut expected = f.p.clone();
|
||||
set_order(
|
||||
@@ -513,7 +513,7 @@ fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_tak
|
||||
let mut s = ready(&f, reverse);
|
||||
decide(&mut s, &f.p, member(&f, 0), decision);
|
||||
if decision == SemanticDecision::Candidate {
|
||||
s.update(&f.p, Event::Presence(member(&f, 0), false));
|
||||
s.update(&f.p, RowEvent::Presence(member(&f, 0), false));
|
||||
}
|
||||
decide(
|
||||
&mut s,
|
||||
@@ -523,7 +523,7 @@ fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_tak
|
||||
);
|
||||
s.update(
|
||||
&f.p,
|
||||
Event::Input("/description".into(), "Only selected membership".into()),
|
||||
RowEvent::Input("/description".into(), "Only selected membership".into()),
|
||||
);
|
||||
let mut expected = f.p.clone();
|
||||
if decision != SemanticDecision::KeepTarget {
|
||||
@@ -565,3 +565,58 @@ fn ordered_presence_keep_take_candidate_remove_only_selected_entry_and_order_tak
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_pair_restore_uses_exact_member_keys_and_never_zips_order_observations() {
|
||||
let f = fixture(0, false, true);
|
||||
let mut state = ready(&f, false);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
member(&f, 2),
|
||||
SemanticDecision::TakeSource,
|
||||
);
|
||||
decide(
|
||||
&mut state,
|
||||
&f.p,
|
||||
FacetSelector::Order(f.which),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
state.update(&f.p, RowEvent::OrderMove(f.which, 3, -1));
|
||||
assert!(state.can_apply(), "{}", errors(&state));
|
||||
let project = state.proposed.clone().unwrap();
|
||||
let mut reopened = State::new(&project, f.trace).unwrap();
|
||||
let exact = history::reviewed_pairs(&reopened, &project).unwrap();
|
||||
assert_eq!(exact, BTreeMap::from([(f.rows[0][2], f.rows[1][2])]));
|
||||
assert_eq!(
|
||||
project.traces[&f.trace]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
reopened.update(&project, RowEvent::RestoreReviewedReferences);
|
||||
assert_eq!(reopened.references, exact);
|
||||
assert!(reopened.decisions.is_empty());
|
||||
assert_eq!(reopened.proposed.as_ref(), Some(&project));
|
||||
reopened.references.insert(f.rows[0][2], f.rows[1][0]);
|
||||
let prior = reopened.references.clone();
|
||||
reopened.update(&project, RowEvent::RestoreReviewedReferences);
|
||||
assert!(reopened.error.as_ref().unwrap().contains("conflicts"));
|
||||
assert_eq!(
|
||||
reopened.references, prior,
|
||||
"Restore is atomic on disagreement"
|
||||
);
|
||||
reopened.references.clear();
|
||||
reopened.update(&project, RowEvent::Reset(member(&f, 2), true));
|
||||
assert!(
|
||||
history::reviewed_pairs(&reopened, &project)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Saved {
|
||||
pub(super) struct Saved {
|
||||
version: u32,
|
||||
base_identity: Id,
|
||||
base_fingerprint: String,
|
||||
@@ -31,11 +31,143 @@ struct Saved {
|
||||
rows: Vec<(FacetSelector, String, Option<Id>)>,
|
||||
#[serde(default)]
|
||||
orders: Vec<(OrderedCollection, Vec<Id>)>,
|
||||
#[serde(default)]
|
||||
attachments: attachments::Saved,
|
||||
#[serde(default)]
|
||||
artifacts: artifacts::Saved,
|
||||
reference_oa: Option<Id>,
|
||||
reference_sa: Option<Id>,
|
||||
reference_search: String,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RowPayload {
|
||||
anchor: Id,
|
||||
trace: Option<Id>,
|
||||
direction: TransitionDirection,
|
||||
selected: Option<FacetSelector>,
|
||||
decisions: Vec<(FacetSelector, SemanticDecision)>,
|
||||
resets: BTreeSet<FacetSelector>,
|
||||
staged: BTreeSet<FacetSelector>,
|
||||
presence: Vec<(FacetSelector, bool)>,
|
||||
member_family: keyed::Family,
|
||||
member_oa: Option<Id>,
|
||||
member_sa: Option<Id>,
|
||||
literal: String,
|
||||
literal_oa: bool,
|
||||
literal_sa: bool,
|
||||
annotation_oa: Option<AnnotationKey>,
|
||||
annotation_sa: Option<AnnotationKey>,
|
||||
draft: Option<RecoveryDraft>,
|
||||
group_drafts: Vec<(FacetSelector, RecoveryDraft)>,
|
||||
rows: Vec<(FacetSelector, String, Option<Id>)>,
|
||||
#[serde(default)]
|
||||
orders: Vec<(OrderedCollection, Vec<Id>)>,
|
||||
#[serde(default)]
|
||||
attachments: attachments::Saved,
|
||||
#[serde(default)]
|
||||
artifacts: artifacts::Saved,
|
||||
reference_oa: Option<Id>,
|
||||
reference_sa: Option<Id>,
|
||||
reference_search: String,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SavedRow {
|
||||
address: RowAddress,
|
||||
included: bool,
|
||||
draft: RowPayload,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SavedBatch {
|
||||
version: u32,
|
||||
base_identity: Id,
|
||||
base_fingerprint: String,
|
||||
direction: TransitionDirection,
|
||||
references: BTreeMap<Id, Id>,
|
||||
accepted_impacts: Vec<SemanticImpact>,
|
||||
active: Option<RowAddress>,
|
||||
rows: Vec<SavedRow>,
|
||||
pending: Option<RowPayload>,
|
||||
add_trace: Option<Id>,
|
||||
}
|
||||
impl RowPayload {
|
||||
fn from_row(row: &ReviewRow) -> Result<Self, String> {
|
||||
let saved: Saved =
|
||||
serde_json::from_value(row.recovery_snapshot()?).map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
anchor: saved.anchor,
|
||||
trace: saved.trace,
|
||||
direction: saved.direction,
|
||||
selected: saved.selected,
|
||||
decisions: saved.decisions,
|
||||
resets: saved.resets,
|
||||
staged: saved.staged,
|
||||
presence: saved.presence,
|
||||
member_family: saved.member_family,
|
||||
member_oa: saved.member_oa,
|
||||
member_sa: saved.member_sa,
|
||||
literal: saved.literal,
|
||||
literal_oa: saved.literal_oa,
|
||||
literal_sa: saved.literal_sa,
|
||||
annotation_oa: saved.annotation_oa,
|
||||
annotation_sa: saved.annotation_sa,
|
||||
draft: saved.draft,
|
||||
group_drafts: saved.group_drafts,
|
||||
rows: saved.rows,
|
||||
orders: saved.orders,
|
||||
attachments: saved.attachments,
|
||||
artifacts: saved.artifacts,
|
||||
reference_oa: saved.reference_oa,
|
||||
reference_sa: saved.reference_sa,
|
||||
reference_search: saved.reference_search,
|
||||
})
|
||||
}
|
||||
fn restore(
|
||||
self,
|
||||
project: &Project,
|
||||
references: &BTreeMap<Id, Id>,
|
||||
) -> Result<ReviewRow, String> {
|
||||
ReviewRow::restore_saved(
|
||||
project,
|
||||
Saved {
|
||||
version: 1,
|
||||
base_identity: project.id,
|
||||
base_fingerprint: String::new(),
|
||||
references: references.clone(),
|
||||
accepted_impacts: vec![],
|
||||
anchor: self.anchor,
|
||||
trace: self.trace,
|
||||
direction: self.direction,
|
||||
selected: self.selected,
|
||||
decisions: self.decisions,
|
||||
resets: self.resets,
|
||||
staged: self.staged,
|
||||
presence: self.presence,
|
||||
member_family: self.member_family,
|
||||
member_oa: self.member_oa,
|
||||
member_sa: self.member_sa,
|
||||
literal: self.literal,
|
||||
literal_oa: self.literal_oa,
|
||||
literal_sa: self.literal_sa,
|
||||
annotation_oa: self.annotation_oa,
|
||||
annotation_sa: self.annotation_sa,
|
||||
draft: self.draft,
|
||||
group_drafts: self.group_drafts,
|
||||
rows: self.rows,
|
||||
orders: self.orders,
|
||||
attachments: self.attachments,
|
||||
artifacts: self.artifacts,
|
||||
reference_oa: self.reference_oa,
|
||||
reference_sa: self.reference_sa,
|
||||
reference_search: self.reference_search,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn unique<K: Ord, V>(values: Vec<(K, V)>) -> Result<BTreeMap<K, V>, String> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (key, value) in values {
|
||||
@@ -46,7 +178,7 @@ fn unique<K: Ord, V>(values: Vec<(K, V)>) -> Result<BTreeMap<K, V>, String> {
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
impl State {
|
||||
impl ReviewRow {
|
||||
pub fn recovery_snapshot(&self) -> Result<Value, String> {
|
||||
let key = |address: &keyed_rows::AddressDraft| {
|
||||
address.selected.as_ref().map(|k| AnnotationKey {
|
||||
@@ -100,6 +232,8 @@ impl State {
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
attachments: self.attachments.recovery_snapshot(),
|
||||
artifacts: self.artifacts.recovery_snapshot(),
|
||||
reference_oa: self.reference_oa.as_ref().map(|c| c.id),
|
||||
orders: self
|
||||
.order_drafts
|
||||
@@ -124,6 +258,10 @@ impl State {
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Self::restore_saved(project, saved)
|
||||
}
|
||||
|
||||
fn restore_saved(project: &Project, saved: Saved) -> Result<Self, String> {
|
||||
let count = [
|
||||
saved.decisions.len(),
|
||||
saved.references.len(),
|
||||
@@ -153,6 +291,7 @@ impl State {
|
||||
}) {
|
||||
return Err("Semantic recovery trace and selected object disagree.".into());
|
||||
}
|
||||
state.base_fingerprint = project_fingerprint(project);
|
||||
state.trace = saved.trace;
|
||||
state.direction = saved.direction;
|
||||
state.load_trace(project);
|
||||
@@ -227,6 +366,17 @@ impl State {
|
||||
)?;
|
||||
state.reference_oa = saved.reference_oa.map(|id| view::choice(project, id));
|
||||
state.order_drafts = unique(saved.orders)?;
|
||||
state.attachments = attachments::Editor::from_recovery(
|
||||
project,
|
||||
state.direction.target_layer(),
|
||||
saved.attachments,
|
||||
)?;
|
||||
if let Some(trace) = state.trace.and_then(|id| project.traces.get(&id)) {
|
||||
state.artifacts =
|
||||
artifacts::Editor::from_recovery(project, trace, state.direction, saved.artifacts)?;
|
||||
} else if !saved.artifacts.is_empty() {
|
||||
return Err("Recovered artifact drafts need an exact owner trace.".into());
|
||||
}
|
||||
state.reference_sa = saved.reference_sa.map(|id| view::choice(project, id));
|
||||
state.reference_search = saved.reference_search;
|
||||
state.accepted_impacts = saved.accepted_impacts;
|
||||
@@ -237,6 +387,230 @@ impl State {
|
||||
}
|
||||
}
|
||||
|
||||
fn batch_limits(value: &Value) -> Result<(), String> {
|
||||
let mut pending = vec![value];
|
||||
let mut count = 0usize;
|
||||
let mut bytes = 0usize;
|
||||
while let Some(value) = pending.pop() {
|
||||
count = count.saturating_add(1);
|
||||
match value {
|
||||
Value::String(text) => bytes = bytes.saturating_add(text.len()),
|
||||
Value::Array(values) => pending.extend(values),
|
||||
Value::Object(values) => {
|
||||
bytes = values
|
||||
.keys()
|
||||
.fold(bytes, |total, key| total.saturating_add(key.len()));
|
||||
pending.extend(values.values());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if count > crate::source_workspace::LIMITS.model.max_records
|
||||
|| bytes > crate::source_workspace::LIMITS.model.max_source_bytes
|
||||
{
|
||||
return Err("Semantic recovery exceeds the aggregate draft limits.".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// A source-5 upgrade may reconstruct the same accepted records with a new
|
||||
/// schema fingerprint. The caller supplies the independently verified legacy
|
||||
/// base; every saved row is still decoded and replanned through strict restore.
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
project: &Project,
|
||||
legacy_base: &str,
|
||||
value: Value,
|
||||
) -> Result<Self, String> {
|
||||
batch_limits(&value)?;
|
||||
let value = match value.get("version").and_then(Value::as_u64) {
|
||||
Some(1) => {
|
||||
let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
|
||||
return Err(
|
||||
"Semantic source-5 recovery does not match the verified legacy base."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
saved.base_fingerprint = project_fingerprint(project);
|
||||
saved.accepted_impacts.clear();
|
||||
serde_json::to_value(saved).map_err(|e| e.to_string())?
|
||||
}
|
||||
Some(2) => {
|
||||
let mut saved: SavedBatch =
|
||||
serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
if saved.base_identity != project.id || saved.base_fingerprint != legacy_base {
|
||||
return Err(
|
||||
"Semantic source-5 recovery does not match the verified legacy base."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
saved.base_fingerprint = project_fingerprint(project);
|
||||
saved.accepted_impacts.clear();
|
||||
serde_json::to_value(saved).map_err(|e| e.to_string())?
|
||||
}
|
||||
_ => return Err("Unsupported semantic recovery version.".into()),
|
||||
};
|
||||
Self::from_recovery(project, value)
|
||||
}
|
||||
|
||||
pub fn recovery_snapshot(&self) -> Result<Value, String> {
|
||||
let rows = self
|
||||
.order
|
||||
.iter()
|
||||
.map(|address| {
|
||||
Ok(SavedRow {
|
||||
address: *address,
|
||||
included: self.included.contains(address),
|
||||
draft: RowPayload::from_row(
|
||||
self.row(*address)
|
||||
.ok_or("Retained review row is missing.")?,
|
||||
)?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, String>>()?;
|
||||
if rows.len() > batch::MAX_REVIEW_ROWS {
|
||||
return Err("Semantic recovery exceeds the supported row limit.".into());
|
||||
}
|
||||
let saved = SavedBatch {
|
||||
version: 2,
|
||||
base_identity: self.base_identity,
|
||||
base_fingerprint: self.base_fingerprint.clone(),
|
||||
direction: self.direction,
|
||||
references: self.references.clone(),
|
||||
accepted_impacts: self.accepted_impacts.clone(),
|
||||
active: self.active_address,
|
||||
rows,
|
||||
pending: if self.active_address.is_none() {
|
||||
Some(RowPayload::from_row(&self.active)?)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
add_trace: self.add_trace.as_ref().map(|choice| choice.id),
|
||||
};
|
||||
let value = serde_json::to_value(saved).map_err(|error| error.to_string())?;
|
||||
batch_limits(&value)?;
|
||||
Ok(value)
|
||||
}
|
||||
pub fn from_recovery(project: &Project, value: Value) -> Result<Self, String> {
|
||||
batch_limits(&value)?;
|
||||
if value.get("version").and_then(Value::as_u64) == Some(1) {
|
||||
let active = ReviewRow::from_recovery(project, value)?;
|
||||
let address = active
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.map(|trace| RowAddress::from_trace(trace, active.direction));
|
||||
let mut state = Self {
|
||||
base: std::sync::Arc::new(project.clone()),
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
direction: active.direction,
|
||||
references: active.references.clone(),
|
||||
accepted_impacts: active.accepted_impacts.clone(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
active,
|
||||
active_address: address,
|
||||
bank: BTreeMap::new(),
|
||||
order: address.into_iter().collect(),
|
||||
included: address.into_iter().collect(),
|
||||
add_trace: None,
|
||||
notice: None,
|
||||
};
|
||||
state.refresh(project);
|
||||
return Ok(state);
|
||||
}
|
||||
let saved: SavedBatch = serde_json::from_value(value).map_err(|error| error.to_string())?;
|
||||
if saved.version != 2
|
||||
|| saved.base_identity != project.id
|
||||
|| saved.base_fingerprint != project_fingerprint(project)
|
||||
{
|
||||
return Err(
|
||||
"Semantic review recovery does not match the accepted model identity and revision."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if saved.rows.len() > batch::MAX_REVIEW_ROWS {
|
||||
return Err("Semantic recovery exceeds the supported row limit.".into());
|
||||
}
|
||||
let mut bank = BTreeMap::new();
|
||||
let mut order = vec![];
|
||||
let mut included = BTreeSet::new();
|
||||
let mut sources = BTreeSet::new();
|
||||
for saved_row in saved.rows {
|
||||
let address = saved_row.address;
|
||||
let direction = address.direction(project)?;
|
||||
let row = saved_row.draft.restore(project, &saved.references)?;
|
||||
if row.direction != direction
|
||||
|| row.trace != Some(address.trace)
|
||||
|| row.target(project) != Some(address.target)
|
||||
{
|
||||
return Err(
|
||||
"Recovered row draft does not match its stable source/target/trace address."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if bank.insert(address, row).is_some() {
|
||||
return Err("Recovery contains a duplicate semantic row address.".into());
|
||||
}
|
||||
order.push(address);
|
||||
if saved_row.included {
|
||||
if !sources.insert(address.source) {
|
||||
return Err(
|
||||
"Recovery contains multiple included histories for one source.".into(),
|
||||
);
|
||||
}
|
||||
included.insert(address);
|
||||
}
|
||||
}
|
||||
let active = match (saved.active, saved.pending) {
|
||||
(Some(address), None) => {
|
||||
if address.direction(project)? != saved.direction {
|
||||
return Err("Recovery active row has a different direction.".into());
|
||||
}
|
||||
bank.remove(&address)
|
||||
.ok_or("Recovery active review row is absent.")?
|
||||
}
|
||||
(None, Some(pending)) if bank.is_empty() => {
|
||||
let row = pending.restore(project, &saved.references)?;
|
||||
if row.trace.is_some() || row.direction != saved.direction {
|
||||
return Err("Recovery pending row is not an unresolved trace selection.".into());
|
||||
}
|
||||
row
|
||||
}
|
||||
_ => return Err("Recovery has an invalid active/pending review row.".into()),
|
||||
};
|
||||
if saved
|
||||
.add_trace
|
||||
.is_some_and(|id| !project.traces.contains_key(&id))
|
||||
{
|
||||
return Err("Recovery add-row selection is not an existing trace.".into());
|
||||
}
|
||||
let mut state = Self {
|
||||
base: std::sync::Arc::new(project.clone()),
|
||||
base_identity: saved.base_identity,
|
||||
base_fingerprint: saved.base_fingerprint,
|
||||
direction: saved.direction,
|
||||
references: saved.references,
|
||||
accepted_impacts: saved.accepted_impacts,
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
active,
|
||||
active_address: saved.active,
|
||||
bank,
|
||||
order,
|
||||
included,
|
||||
add_trace: saved.add_trace.map(|id| view::choice(project, id)),
|
||||
notice: None,
|
||||
};
|
||||
// Recompose all included rows once. Excluded invalid raw drafts remain recoverable.
|
||||
state.refresh(project);
|
||||
Ok(state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -265,14 +639,14 @@ mod tests {
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
for (raw, valid) in [("7", true), ("-", false)] {
|
||||
let mut state = State::new(&p, oa).unwrap();
|
||||
state.update(&p, Event::Select(facet.clone()));
|
||||
state.update(&p, RowEvent::Select(facet.clone()));
|
||||
state.update(
|
||||
&p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
state.update(&p, Event::Input(path.into(), raw.into()));
|
||||
state.update(&p, RowEvent::Input(path.into(), raw.into()));
|
||||
assert_eq!(state.can_apply(), valid);
|
||||
let current = state.recovery_snapshot().unwrap();
|
||||
let current = state.active.recovery_snapshot().unwrap();
|
||||
let mut old = current.clone();
|
||||
assert_eq!(old["version"], 1);
|
||||
assert_eq!(
|
||||
@@ -283,7 +657,7 @@ mod tests {
|
||||
assert!(restored.order_drafts.is_empty());
|
||||
assert_eq!(restored.draft.as_ref().unwrap().inputs[path].raw, raw);
|
||||
assert_eq!(restored.can_apply(), valid);
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), current);
|
||||
assert_eq!(restored.active.recovery_snapshot().unwrap(), current);
|
||||
assert_eq!(
|
||||
p, accepted,
|
||||
"Recovery prepares a preview, never an accepted edit"
|
||||
@@ -316,13 +690,13 @@ mod tests {
|
||||
super::super::tests::add_trace(&mut p, oa, sa, "Recover review");
|
||||
let mut state = State::new(&p, oa).unwrap();
|
||||
let facet = FacetSelector::Data(DataFacet::IntegerMinimum);
|
||||
state.update(&p, Event::Select(facet.clone()));
|
||||
state.update(&p, RowEvent::Select(facet.clone()));
|
||||
state.update(
|
||||
&p,
|
||||
Event::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
|
||||
);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
state.update(&p, Event::Input(path.into(), "-".into()));
|
||||
state.update(&p, RowEvent::Input(path.into(), "-".into()));
|
||||
assert!(!state.can_apply());
|
||||
let saved = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&p, saved.clone()).unwrap();
|
||||
|
||||
@@ -0,0 +1,887 @@
|
||||
//! Independent authored row; session owns planning and canonical references.
|
||||
use super::*;
|
||||
|
||||
pub struct ReviewRow {
|
||||
pub(super) base_identity: Id,
|
||||
pub(super) base_fingerprint: String,
|
||||
pub anchor: Id,
|
||||
pub trace: Option<Id>,
|
||||
pub direction: TransitionDirection,
|
||||
pub facts: Vec<FacetDrift>,
|
||||
pub selected: Option<FacetSelector>,
|
||||
pub decisions: BTreeMap<FacetSelector, SemanticDecision>,
|
||||
pub references: BTreeMap<Id, Id>,
|
||||
pub resets: BTreeSet<FacetSelector>,
|
||||
pub staged: BTreeSet<FacetSelector>,
|
||||
pub presence: BTreeMap<FacetSelector, bool>,
|
||||
pub row_drafts: BTreeMap<FacetSelector, keyed_rows::RowDraft>,
|
||||
pub order_drafts: BTreeMap<OrderedCollection, Vec<Id>>,
|
||||
pub attachments: attachments::Editor,
|
||||
pub artifacts: artifacts::Editor,
|
||||
pub annotation_oa: keyed_rows::AddressDraft,
|
||||
pub annotation_sa: keyed_rows::AddressDraft,
|
||||
pub member_family: keyed::Family,
|
||||
pub member_oa: Option<Choice>,
|
||||
pub member_sa: Option<Choice>,
|
||||
pub literal: String,
|
||||
pub literal_oa: bool,
|
||||
pub literal_sa: bool,
|
||||
pub draft: Option<Draft>,
|
||||
// Each closed constructor has an independent draft, including nested kinds.
|
||||
// Scalar and sibling drafts survive decision and constructor changes.
|
||||
pub group_drafts: BTreeMap<FacetSelector, Draft>,
|
||||
pub plan: Option<SemanticTransitionPlan>,
|
||||
pub proposed: Option<Project>,
|
||||
pub error: Option<String>,
|
||||
pub tab: Tab,
|
||||
pub show_unchanged: bool,
|
||||
pub reference_oa: Option<Choice>,
|
||||
pub reference_sa: Option<Choice>,
|
||||
pub reference_search: String,
|
||||
pub(super) accepted_impacts: Vec<SemanticImpact>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub fn new(project: &Project, selected: Id) -> Result<Self, String> {
|
||||
let explicit = project.traces.get(&selected);
|
||||
let anchor = explicit.map_or(selected, |trace| trace.oa);
|
||||
let layer = project
|
||||
.layer_of(anchor)
|
||||
.ok_or("Select an OA or SA object, relationship or trace to review.")?;
|
||||
let choices: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == anchor || t.sa == anchor)
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return Err("This object has no trace. Create a counterpart or link existing counterparts first.".into());
|
||||
}
|
||||
let trace = explicit
|
||||
.map(|t| t.id)
|
||||
.or_else(|| (choices.len() == 1).then(|| choices[0].id));
|
||||
let mut state = Self {
|
||||
base_identity: project.id,
|
||||
base_fingerprint: project_fingerprint(project),
|
||||
anchor,
|
||||
trace,
|
||||
direction: if layer == Layer::Oa {
|
||||
TransitionDirection::OaToSa
|
||||
} else {
|
||||
TransitionDirection::SaToOa
|
||||
},
|
||||
facts: vec![],
|
||||
selected: None,
|
||||
decisions: BTreeMap::new(),
|
||||
references: BTreeMap::new(),
|
||||
resets: BTreeSet::new(),
|
||||
staged: BTreeSet::new(),
|
||||
presence: BTreeMap::new(),
|
||||
row_drafts: BTreeMap::new(),
|
||||
order_drafts: BTreeMap::new(),
|
||||
attachments: Default::default(),
|
||||
artifacts: Default::default(),
|
||||
annotation_oa: Default::default(),
|
||||
annotation_sa: Default::default(),
|
||||
member_family: keyed::Family::Literal,
|
||||
member_oa: None,
|
||||
member_sa: None,
|
||||
literal: String::new(),
|
||||
literal_oa: true,
|
||||
literal_sa: true,
|
||||
draft: None,
|
||||
group_drafts: BTreeMap::new(),
|
||||
plan: None,
|
||||
proposed: None,
|
||||
error: None,
|
||||
tab: Tab::Facts,
|
||||
show_unchanged: false,
|
||||
reference_oa: None,
|
||||
reference_sa: None,
|
||||
reference_search: String::new(),
|
||||
accepted_impacts: vec![],
|
||||
};
|
||||
state.load_trace(project);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
pub fn target(&self, project: &Project) -> Option<Id> {
|
||||
let trace = project.traces.get(&self.trace?)?;
|
||||
Some(if self.direction == TransitionDirection::OaToSa {
|
||||
trace.sa
|
||||
} else {
|
||||
trace.oa
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn load_trace(&mut self, project: &Project) {
|
||||
self.facts.clear();
|
||||
self.decisions.clear();
|
||||
self.resets.clear();
|
||||
self.staged.clear();
|
||||
self.presence.clear();
|
||||
self.row_drafts.clear();
|
||||
self.order_drafts.clear();
|
||||
self.attachments = Default::default();
|
||||
self.artifacts = Default::default();
|
||||
self.annotation_oa = Default::default();
|
||||
self.annotation_sa = Default::default();
|
||||
self.member_oa = None;
|
||||
self.member_sa = None;
|
||||
self.literal.clear();
|
||||
self.literal_oa = true;
|
||||
self.literal_sa = true;
|
||||
self.selected = None;
|
||||
self.accepted_impacts.clear();
|
||||
self.references.clear();
|
||||
self.draft = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id));
|
||||
self.group_drafts.clear();
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
for facet in constructors::selectors() {
|
||||
if constructors::available(project, trace, &facet)
|
||||
&& let Some(mut draft) = self
|
||||
.target(project)
|
||||
.and_then(|id| Draft::from_project(project, id))
|
||||
{
|
||||
draft.semantic_groups = true;
|
||||
self.group_drafts.insert(facet, draft);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(trace) = self.trace.and_then(|id| project.traces.get(&id)) {
|
||||
self.references.insert(trace.oa, trace.sa);
|
||||
}
|
||||
self.member_family = self
|
||||
.member_families(project)
|
||||
.first()
|
||||
.copied()
|
||||
.unwrap_or(keyed::Family::Literal);
|
||||
self.refresh(project);
|
||||
}
|
||||
|
||||
pub fn update(&mut self, project: &Project, event: RowEvent) {
|
||||
let mut replan = true;
|
||||
let result = match event {
|
||||
#[cfg(test)]
|
||||
RowEvent::Trace(choice) => {
|
||||
if !project
|
||||
.traces
|
||||
.get(&choice.id)
|
||||
.is_some_and(|t| t.oa == self.anchor || t.sa == self.anchor)
|
||||
{
|
||||
Err("Choose one of this object's existing traces.".into())
|
||||
} else {
|
||||
self.trace = Some(choice.id);
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
RowEvent::Direction(direction) => {
|
||||
self.direction = direction;
|
||||
self.load_trace(project);
|
||||
return;
|
||||
}
|
||||
RowEvent::Select(facet) => {
|
||||
self.selected = Some(facet);
|
||||
self.tab = Tab::Facts;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::Decide(facet, decision) => {
|
||||
if let Some(decision) = decision {
|
||||
if !self.editable(project, &facet)
|
||||
&& !(decision == SemanticDecision::KeepTarget
|
||||
&& self.can_acknowledge_constructor(project, &facet))
|
||||
{
|
||||
Err("This fact needs the structured review workflow; its history remains unchanged.".into())
|
||||
} else {
|
||||
if artifacts::supported(&facet)
|
||||
&& decision == SemanticDecision::Candidate
|
||||
&& let Err(error) = self.artifacts.ensure(
|
||||
project,
|
||||
&facet,
|
||||
self.direction.target_layer(),
|
||||
)
|
||||
{
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
if attachments::supported(&facet)
|
||||
&& decision == SemanticDecision::Candidate
|
||||
&& let Err(error) = self.attachments.ensure(
|
||||
project,
|
||||
&facet,
|
||||
self.direction.target_layer(),
|
||||
)
|
||||
{
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
if let FacetSelector::Order(which) = facet
|
||||
&& decision == SemanticDecision::Candidate
|
||||
{
|
||||
if let Err(error) = self.initialize_order(project, which) {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
} else if keyed_rows::supported(&facet) {
|
||||
self.initialize_row(project, &facet);
|
||||
} else if keyed::supported(&facet) {
|
||||
let target_presence = self
|
||||
.effective_trace(project)
|
||||
.and_then(|t| capture_facet(project, t.oa, t.sa, &facet).ok())
|
||||
.and_then(|f| scalar::value(&f, self.direction.target_layer()))
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
self.presence
|
||||
.entry(facet.clone())
|
||||
.or_insert(target_presence);
|
||||
}
|
||||
self.decisions.insert(facet, decision);
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
self.decisions.remove(&facet);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
RowEvent::Input(path, value) => self.edit(project, &path, |d| {
|
||||
d.input(&path, value);
|
||||
Ok(())
|
||||
}),
|
||||
RowEvent::Text(path, action) => self.edit(project, &path, |d| {
|
||||
d.edit_text(&path, action);
|
||||
Ok(())
|
||||
}),
|
||||
RowEvent::Shape(path, value) => {
|
||||
self.edit(project, &path, |d| d.shape_scoped(&path, value))
|
||||
}
|
||||
RowEvent::Variant(path, value) => {
|
||||
self.edit(project, &path, |d| d.variant_scoped(&path, value))
|
||||
}
|
||||
RowEvent::Array(path, index, direction) => {
|
||||
self.edit(project, &path, |d| d.array_scoped(&path, index, direction))
|
||||
}
|
||||
RowEvent::Append(path, value) => {
|
||||
self.edit(project, &path, |d| d.append_scoped(&path, value))
|
||||
}
|
||||
RowEvent::ValuePath(path, key) => {
|
||||
self.edit(project, &path, |d| d.value_path_scoped(&path, key))
|
||||
}
|
||||
RowEvent::ValueBrowse(path, scope, page) => {
|
||||
replan = false;
|
||||
self.edit(project, &path, |draft| {
|
||||
draft
|
||||
.value_browsers
|
||||
.insert(path.clone(), crate::forms::ValueBrowser { scope, page });
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
RowEvent::ReferenceBrowse(path, query, page) => {
|
||||
replan = false;
|
||||
self.edit(project, &path, |draft| {
|
||||
let page = if draft
|
||||
.reference_browsers
|
||||
.get(&path)
|
||||
.is_some_and(|b| b.query == query)
|
||||
{
|
||||
page
|
||||
} else {
|
||||
0
|
||||
};
|
||||
draft
|
||||
.reference_browsers
|
||||
.insert(path.clone(), crate::forms::ReferenceBrowser { query, page });
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
RowEvent::Tab(tab) => {
|
||||
self.tab = tab;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::ShowUnchanged(show) => {
|
||||
self.show_unchanged = show;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::ReferenceOa(choice) => {
|
||||
self.reference_oa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::ReferenceSa(choice) => {
|
||||
self.reference_sa = Some(choice);
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::ReferenceSearch(value) => {
|
||||
self.reference_search = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::AddReference => match (&self.reference_oa, &self.reference_sa) {
|
||||
(Some(oa), Some(sa)) => {
|
||||
self.references.insert(oa.id, sa.id);
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("Choose both an Operational and a System reference.".into()),
|
||||
},
|
||||
RowEvent::RestoreReviewedReferences => {
|
||||
Err("Restore reviewed pairs through the complete review session.".into())
|
||||
}
|
||||
RowEvent::RemoveReference(id) => {
|
||||
self.references.remove(&id);
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::AcceptImpact(index, accepted) => {
|
||||
if let Some(impact) = self
|
||||
.plan
|
||||
.as_ref()
|
||||
.and_then(|p| p.impacts.get(index))
|
||||
.cloned()
|
||||
{
|
||||
self.accepted_impacts.retain(|i| i.object != impact.object);
|
||||
if accepted {
|
||||
self.accepted_impacts.push(impact);
|
||||
}
|
||||
}
|
||||
self.refresh(project);
|
||||
return;
|
||||
}
|
||||
RowEvent::Preview => Ok(()),
|
||||
RowEvent::OrderMove(which, index, delta) => self.move_order(which, index, delta),
|
||||
RowEvent::OrderReconcile(which) => self.reconcile_order(project, which),
|
||||
RowEvent::Reset(facet, reset) => self.reset_history(project, facet, reset),
|
||||
RowEvent::Presence(facet, present) => {
|
||||
if keyed::supported(&facet)
|
||||
&& self.decisions.get(&facet) == Some(&SemanticDecision::Candidate)
|
||||
{
|
||||
self.presence.insert(facet, present);
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Choose Set final value for this member before editing presence.".into())
|
||||
}
|
||||
}
|
||||
RowEvent::MemberFamily(family) => {
|
||||
self.member_family = family;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::MemberOa(choice) => {
|
||||
self.member_oa = choice;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::MemberSa(choice) => {
|
||||
self.member_sa = choice;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::Literal(value) => {
|
||||
self.literal = value;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::LiteralSide(layer, present) => {
|
||||
if layer == Layer::Oa {
|
||||
self.literal_oa = present;
|
||||
} else {
|
||||
self.literal_sa = present;
|
||||
}
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::EditPair(facet) => {
|
||||
replan = false;
|
||||
if artifacts::supported(&facet) {
|
||||
self.artifacts.select_pair(project, &facet).map(|()| {
|
||||
self.tab = Tab::Artifacts;
|
||||
})
|
||||
} else if attachments::supported(&facet) {
|
||||
self.attachments.select_pair(project, &facet).map(|()| {
|
||||
self.tab = Tab::Attachments;
|
||||
})
|
||||
} else {
|
||||
self.edit_pair(project, facet)
|
||||
}
|
||||
}
|
||||
RowEvent::Artifact(event) => {
|
||||
replan = !matches!(
|
||||
event,
|
||||
artifacts::Event::Pick(_, _) | artifacts::Event::Search(_)
|
||||
);
|
||||
let edited = match &event {
|
||||
artifacts::Event::Input(facet, _) | artifacts::Event::Description(facet, _) => {
|
||||
Some(facet)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if edited.is_some_and(|facet| {
|
||||
self.decisions.get(facet) != Some(&SemanticDecision::Candidate)
|
||||
}) {
|
||||
Err("Choose Set final value before editing this exact artifact facet.".into())
|
||||
} else {
|
||||
match self.effective_trace(project) {
|
||||
Some(trace) => self
|
||||
.artifacts
|
||||
.update(project, &trace, self.direction, event)
|
||||
.map(|facets| {
|
||||
if let Some(facets) = facets {
|
||||
let pair = self.artifacts.pair();
|
||||
if let Some((oa, sa)) = pair.oa.zip(pair.sa) {
|
||||
self.references.insert(oa, sa);
|
||||
}
|
||||
self.selected = facets.first().cloned();
|
||||
self.staged.extend(facets);
|
||||
self.tab = Tab::Facts;
|
||||
}
|
||||
}),
|
||||
None => Err("Choose a trace first.".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
RowEvent::Attachment(event) => {
|
||||
replan = matches!(event, attachments::Event::Stage);
|
||||
match self.effective_trace(project) {
|
||||
Some(trace) => self
|
||||
.attachments
|
||||
.update(project, &trace, event)
|
||||
.map(|facet| {
|
||||
if let Some(facet) = facet {
|
||||
if let Some((oa, sa)) = attachments::explicit_pair(&facet) {
|
||||
self.references.insert(oa, sa);
|
||||
}
|
||||
self.staged.insert(facet.clone());
|
||||
self.selected = Some(facet);
|
||||
self.tab = Tab::Facts;
|
||||
}
|
||||
}),
|
||||
None => Err("Choose a trace first.".into()),
|
||||
}
|
||||
}
|
||||
RowEvent::StageMember => self.stage_member(project),
|
||||
RowEvent::DropStaged(facet) => {
|
||||
if self.staged.remove(&facet) {
|
||||
self.decisions.remove(&facet);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::AnnotationAddress(layer, key) => {
|
||||
self.annotation_address_mut(layer).selected = key;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
RowEvent::AnnotationBrowse(layer, scope, page) => {
|
||||
let address = self.annotation_address_mut(layer);
|
||||
address.scope = scope;
|
||||
address.page = page;
|
||||
replan = false;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
RowEvent::RowExpression(facet, expression) => self.edit_row(&facet, |draft| {
|
||||
draft.editor = text_editor::Content::with_text(&expression);
|
||||
draft.expression = expression;
|
||||
}),
|
||||
RowEvent::RowText(facet, action) => self.edit_row(&facet, |draft| {
|
||||
draft.editor.perform(action);
|
||||
draft.expression = draft.editor.text();
|
||||
}),
|
||||
RowEvent::RowLocal(facet, local) => self.edit_row(&facet, |draft| {
|
||||
draft.local = Some(local);
|
||||
}),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
self.error = Some(error);
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
return;
|
||||
}
|
||||
if replan {
|
||||
self.accepted_impacts.clear();
|
||||
self.refresh(project);
|
||||
}
|
||||
}
|
||||
|
||||
fn edit(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
path: &str,
|
||||
action: impl FnOnce(&mut Draft) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let facet = self.selected.as_ref().ok_or("Choose a property first.")?;
|
||||
if self.decisions.get(facet) != Some(&SemanticDecision::Candidate) {
|
||||
return Err("Choose Set final value before editing this property.".into());
|
||||
}
|
||||
let roots = self
|
||||
.candidate_roots(project, facet)
|
||||
.ok_or("Selected property is unavailable.")?;
|
||||
if !roots
|
||||
.iter()
|
||||
.any(|root| path == root || path.starts_with(&format!("{root}/")))
|
||||
{
|
||||
return Err("Only the selected review property can change in this draft.".into());
|
||||
}
|
||||
let draft = if attachments::supported(facet) {
|
||||
self.attachments.drafts.get_mut(facet)
|
||||
} else if scalar::grouped(facet) {
|
||||
self.group_drafts.get_mut(facet)
|
||||
} else {
|
||||
self.draft.as_mut()
|
||||
}
|
||||
.ok_or("Choose an existing target.")?;
|
||||
// The scoped view sends only the selected property or closed group roots.
|
||||
// Protected identity and top-level body/perspective controls are never exposed.
|
||||
if path == "/id" || path == "/alias" || path == "/body" || path == "/layer" {
|
||||
return Err("Semantic candidates retain identity, alias and perspective.".into());
|
||||
}
|
||||
action(draft)
|
||||
}
|
||||
|
||||
pub(super) fn candidate_roots(
|
||||
&self,
|
||||
project: &Project,
|
||||
facet: &FacetSelector,
|
||||
) -> Option<Vec<String>> {
|
||||
if attachments::supported(facet) {
|
||||
attachments::path(project, facet, self.direction.target_layer()).map(|path| vec![path])
|
||||
} else {
|
||||
self.target(project)
|
||||
.and_then(|id| constructors::roots(project, id, facet))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn editable(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||
if artifacts::supported(facet) {
|
||||
return self.effective_trace(project).is_some_and(|trace| {
|
||||
self.artifacts
|
||||
.editable(project, &trace, facet, self.direction.target_layer())
|
||||
});
|
||||
}
|
||||
if attachments::supported(facet) {
|
||||
return attachments::path(project, facet, self.direction.target_layer()).is_some()
|
||||
&& self.effective_trace(project).is_some_and(|trace| {
|
||||
capture_facet(project, trace.oa, trace.sa, facet).is_ok()
|
||||
});
|
||||
}
|
||||
if keyed::supported(facet) || matches!(facet, FacetSelector::Order(_)) {
|
||||
return self
|
||||
.effective_trace(project)
|
||||
.is_some_and(|trace| capture_facet(project, trace.oa, trace.sa, facet).is_ok());
|
||||
}
|
||||
self.target(project)
|
||||
.and_then(|id| scalar::binding(project, id, facet))
|
||||
.is_some()
|
||||
&& (!scalar::grouped(facet)
|
||||
|| (self.group_drafts.contains_key(facet)
|
||||
&& self
|
||||
.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.is_some_and(|trace| constructors::available(project, trace, facet))))
|
||||
}
|
||||
|
||||
pub(super) fn candidate_draft(&self, facet: &FacetSelector) -> Option<&Draft> {
|
||||
if attachments::supported(facet) {
|
||||
self.attachments.drafts.get(facet)
|
||||
} else if scalar::grouped(facet) {
|
||||
self.group_drafts.get(facet)
|
||||
} else {
|
||||
self.draft.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_acknowledge_constructor(&self, project: &Project, facet: &FacetSelector) -> bool {
|
||||
if !scalar::grouped(facet) {
|
||||
return false;
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
return false;
|
||||
};
|
||||
if trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.is_some_and(|b| b.facets.iter().any(|f| f.selector() == *facet))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
capture_facet(project, trace.oa, trace.sa, facet)
|
||||
.ok()
|
||||
.is_some_and(|current| {
|
||||
scalar::value(¤t, Layer::Oa).is_some()
|
||||
&& scalar::observation(¤t, Layer::Oa)
|
||||
== scalar::observation(¤t, Layer::Sa)
|
||||
})
|
||||
}
|
||||
|
||||
/// Compose selected values for both the candidate and dependent form
|
||||
/// controls. Incomplete inputs retain their stored shape only for controls;
|
||||
/// request construction always requires every selected input to parse.
|
||||
pub(super) fn combined_value(
|
||||
&self,
|
||||
project: &Project,
|
||||
form_context: bool,
|
||||
) -> Result<Value, String> {
|
||||
self.combine(project, form_context, false)
|
||||
}
|
||||
|
||||
pub(super) fn combine(
|
||||
&self,
|
||||
project: &Project,
|
||||
form_context: bool,
|
||||
skip_orders: bool,
|
||||
) -> Result<Value, String> {
|
||||
let trace = self.effective_trace(project).ok_or("Choose a trace.")?;
|
||||
let (source, target) = if self.direction == TransitionDirection::OaToSa {
|
||||
(trace.oa, trace.sa)
|
||||
} else {
|
||||
(trace.sa, trace.oa)
|
||||
};
|
||||
let mut final_value = Draft::from_project(project, target)
|
||||
.ok_or("Target has no editable record.")?
|
||||
.value;
|
||||
let mut map = BTreeMap::new();
|
||||
for (oa, sa) in &self.references {
|
||||
let (from, to) = if self.direction == TransitionDirection::OaToSa {
|
||||
(*oa, *sa)
|
||||
} else {
|
||||
(*sa, *oa)
|
||||
};
|
||||
if map.insert(from, to).is_some_and(|old| old != to) {
|
||||
return Err("Reverse reference correspondence is ambiguous.".into());
|
||||
}
|
||||
}
|
||||
map.insert(source, target);
|
||||
let original = final_value.clone();
|
||||
let mut group_candidates = vec![];
|
||||
// Outer groups precede nested groups and leaves. The core rejects actual
|
||||
// overlapping replacement scopes; this order also supports first-equal
|
||||
// discriminator acknowledgment together with explicit descendant values.
|
||||
let mut decisions: Vec<_> = self.decisions.iter().collect();
|
||||
decisions.sort_by_key(|(facet, _)| {
|
||||
let group = match facet {
|
||||
FacetSelector::Occurrence(OccurrenceFacet::Constructor) => 0,
|
||||
facet if scalar::grouped(facet) => 1,
|
||||
FacetSelector::Order(_) => 3,
|
||||
_ => 2,
|
||||
};
|
||||
(
|
||||
group,
|
||||
keyed_rows::target_selector(facet, self.direction.target_layer()),
|
||||
)
|
||||
});
|
||||
for (facet, decision) in decisions {
|
||||
if attachments::supported(facet)
|
||||
|| artifacts::supported(facet)
|
||||
|| *decision == SemanticDecision::KeepTarget
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let FacetSelector::Order(which) = facet {
|
||||
if !skip_orders {
|
||||
// Invalid order drafts remain visible while dependent controls use the
|
||||
// surviving membership; request construction still rejects them.
|
||||
if let Err(error) =
|
||||
self.project_order(project, &trace, &mut final_value, *which, *decision)
|
||||
&& !form_context
|
||||
{
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if matches!(facet, FacetSelector::Member { collection, .. } if ordered::ordered(*collection).is_some())
|
||||
{
|
||||
self.project_ordered_member(
|
||||
project,
|
||||
&trace,
|
||||
&mut final_value,
|
||||
facet,
|
||||
*decision,
|
||||
form_context,
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if keyed::supported(facet) {
|
||||
self.keyed_value(project, &trace, &mut final_value, facet, *decision)?;
|
||||
continue;
|
||||
}
|
||||
let roots = constructors::roots(project, target, facet)
|
||||
.ok_or("Selected property is not applicable to the target.")?;
|
||||
let supplied = match decision {
|
||||
SemanticDecision::KeepTarget => continue,
|
||||
SemanticDecision::Candidate => {
|
||||
let draft = self
|
||||
.candidate_draft(facet)
|
||||
.ok_or("Target draft is unavailable.")?;
|
||||
match draft.materialize_scopes(&roots) {
|
||||
Ok(value) => value,
|
||||
Err(_) if form_context => draft.value.clone(),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource if scalar::grouped(facet) => {
|
||||
if self.can_acknowledge_constructor(project, facet) {
|
||||
original.clone()
|
||||
} else {
|
||||
constructors::take_value(project, &trace, self.direction, facet, &map)?
|
||||
}
|
||||
}
|
||||
SemanticDecision::TakeSource => {
|
||||
let fact = capture_facet(project, trace.oa, trace.sa, facet)?;
|
||||
let value = scalar::source_value(&fact, self.direction.source_layer(), &map)?;
|
||||
let mut supplied = original.clone();
|
||||
*supplied
|
||||
.pointer_mut(&roots[0])
|
||||
.ok_or("Source property is unavailable.")? = value;
|
||||
supplied
|
||||
}
|
||||
};
|
||||
for path in roots {
|
||||
let value = supplied
|
||||
.pointer(&path)
|
||||
.cloned()
|
||||
.ok_or("Candidate property is no longer available.")?;
|
||||
if scalar::grouped(facet) && *decision == SemanticDecision::Candidate {
|
||||
group_candidates.push((path.clone(), value.clone()));
|
||||
}
|
||||
*final_value.pointer_mut(&path).ok_or("Final target property is unavailable; resolve the overlapping constructor decision.")? = value;
|
||||
}
|
||||
}
|
||||
if !form_context {
|
||||
for (path, authored) in group_candidates {
|
||||
if !original
|
||||
.pointer(&path)
|
||||
.zip(final_value.pointer(&path))
|
||||
.is_some_and(|(original, final_value)| {
|
||||
constructors::authored_agrees(original, &authored, final_value)
|
||||
})
|
||||
{
|
||||
return Err("The constructor candidate and descendant decision propose different final values. Keep the kind or make their final values agree; both drafts are retained.".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(final_value)
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self, project: &Project) {
|
||||
self.plan = None;
|
||||
self.proposed = None;
|
||||
self.error = None;
|
||||
let context = self.combined_value(project, true).ok();
|
||||
if let Some(draft) = &mut self.draft {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
for draft in self.group_drafts.values_mut() {
|
||||
draft.set_context(context.clone());
|
||||
}
|
||||
let Some(trace) = self.effective_trace(project) else {
|
||||
self.error = Some(
|
||||
"Several traces are available. Choose the correspondence and review history above."
|
||||
.into(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
match semantic_facets(project, &trace, &self.references) {
|
||||
Ok(mut facts) => {
|
||||
// Explicit addresses survive catalog normalization, pairing changes and resets.
|
||||
for selector in self.staged.iter().chain(self.decisions.keys()) {
|
||||
if !facts.iter().any(|f| &f.selector == selector) {
|
||||
match capture_facet(project, trace.oa, trace.sa, selector) {
|
||||
Ok(current) => {
|
||||
let previous = trace
|
||||
.baseline
|
||||
.as_ref()
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.and_then(|b| {
|
||||
b.facets.iter().find(|f| f.selector() == *selector)
|
||||
});
|
||||
facts.push(FacetDrift {
|
||||
selector: selector.clone(),
|
||||
drift: facet_drift(¤t, previous),
|
||||
current,
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
facts.sort_by(|a, b| a.selector.cmp(&b.selector));
|
||||
self.facts = facts;
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self
|
||||
.selected
|
||||
.as_ref()
|
||||
.is_none_or(|s| !self.facts.iter().any(|f| &f.selector == s))
|
||||
{
|
||||
self.selected = self
|
||||
.facts
|
||||
.iter()
|
||||
.find(|f| f.drift != Drift::Unchanged)
|
||||
.or(self.facts.first())
|
||||
.map(|f| f.selector.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_references(&self, project: &Project, layer: Layer) -> Vec<Choice> {
|
||||
let historical: BTreeSet<_> = self
|
||||
.facts
|
||||
.iter()
|
||||
.flat_map(|f| scalar::references(&f.current))
|
||||
.chain(
|
||||
self.trace
|
||||
.and_then(|id| project.traces.get(&id))
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.into_iter()
|
||||
.flat_map(|b| b.facets.iter().flat_map(scalar::references)),
|
||||
)
|
||||
.collect();
|
||||
let mut ids: BTreeSet<_> = project
|
||||
.elements
|
||||
.keys()
|
||||
.chain(project.relations.keys())
|
||||
.chain(project.artifact_links.keys())
|
||||
.filter(|id| project.layer_of(**id) == Some(layer))
|
||||
.copied()
|
||||
.collect();
|
||||
ids.extend(historical.into_iter().filter(|id| !project.contains(*id)));
|
||||
let search = self.reference_search.to_lowercase();
|
||||
let mut choices: Vec<_> = ids
|
||||
.into_iter()
|
||||
.map(|id| self.review_choice(project, id))
|
||||
.filter(|c| search.is_empty() || c.label.to_lowercase().contains(&search))
|
||||
.collect();
|
||||
choices.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
choices
|
||||
}
|
||||
}
|
||||
|
||||
pub fn form_event(message: Message) -> Message {
|
||||
let event = match message {
|
||||
Message::Field(path, value) => RowEvent::Input(path, value),
|
||||
Message::FieldText(path, action) => RowEvent::Text(path, action),
|
||||
Message::FieldShape(path, value) => RowEvent::Shape(path, value),
|
||||
Message::FieldVariant(path, value) => RowEvent::Variant(path, value),
|
||||
Message::FieldArray(path, index, direction) => RowEvent::Array(path, index, direction),
|
||||
Message::FieldAppend(path, value) => RowEvent::Append(path, value),
|
||||
Message::FieldValuePath(path, key) => RowEvent::ValuePath(path, key),
|
||||
Message::FieldValueBrowse(path, scope, page) => RowEvent::ValueBrowse(path, scope, page),
|
||||
Message::FieldReferenceBrowse(path, query, page) => {
|
||||
RowEvent::ReferenceBrowse(path, query, page)
|
||||
}
|
||||
_ => RowEvent::Preview,
|
||||
};
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
}
|
||||
@@ -61,6 +61,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::ExchangeItem(ExchangeItemFacet::Nature) => ("ExchangeItem", &["kind"]),
|
||||
F::ExchangeItem(ExchangeItemFacet::Mechanism) => ("ExchangeItem", &["mechanism"]),
|
||||
F::ExchangeItem(ExchangeItemFacet::Supertype) => ("ExchangeItem", &["supertype"]),
|
||||
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
|
||||
F::Interface(InterfaceFacet::Supertype) => ("Interface", &["supertype"]),
|
||||
F::Data(DataFacet::Constructor) => ("DataType", &["definition"]),
|
||||
F::Data(DataFacet::IntegerMinimum) => ("DataType", &["definition", "Integer", "minimum"]),
|
||||
@@ -156,7 +157,14 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
|
||||
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
|
||||
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
|
||||
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
|
||||
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
|
||||
F::Relation(RelationFacet::Oriented) => (
|
||||
if own.kind == "CommunicationMean" {
|
||||
"CommunicationMean"
|
||||
} else {
|
||||
"BoundaryExchange"
|
||||
},
|
||||
&["oriented"],
|
||||
),
|
||||
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
|
||||
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
|
||||
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
|
||||
|
||||
@@ -2,8 +2,10 @@ use super::*;
|
||||
use crate::{app::App, document::Document, workflow::Workflow};
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
|
||||
pub(super) fn send(app: &mut App, event: RowEvent) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
|
||||
state(app).address_event(event),
|
||||
)));
|
||||
}
|
||||
pub(super) fn apply(app: &mut App) {
|
||||
let _ = app.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
@@ -72,7 +74,7 @@ fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(minimum.into(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(minimum.into(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
@@ -85,7 +87,7 @@ fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
|
||||
);
|
||||
assert!(state(&app).can_apply());
|
||||
// Exact event emitted by the upper bound's visible Clear bound button.
|
||||
send(&mut app, Event::Shape(maximum.into(), Value::Null));
|
||||
send(&mut app, RowEvent::Shape(maximum.into(), Value::Null));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[minimum].raw, "-");
|
||||
assert!(
|
||||
@@ -100,15 +102,15 @@ fn independent_review_clear_bound_ignores_retained_invalid_other_facet() {
|
||||
);
|
||||
// Clear must also discard the selected invalid value, without requiring it
|
||||
// to parse before removal. A valid but uncommitted neighboring input survives.
|
||||
send(&mut app, Event::Shape(maximum.into(), json!(30)));
|
||||
send(&mut app, Event::Input(maximum.into(), "29".into()));
|
||||
send(&mut app, RowEvent::Shape(maximum.into(), json!(30)));
|
||||
send(&mut app, RowEvent::Input(maximum.into(), "29".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Shape(minimum.into(), Value::Null));
|
||||
send(&mut app, RowEvent::Shape(minimum.into(), Value::Null));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[maximum].raw,
|
||||
@@ -173,7 +175,7 @@ fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers(
|
||||
FacetSelector::Field(FieldFacet::Minimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(minimum.clone(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(minimum.clone(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
FacetSelector::Field(FieldFacet::Minimum),
|
||||
@@ -184,11 +186,11 @@ fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers(
|
||||
FacetSelector::Field(FieldFacet::Default),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(first.clone(), "-".into()));
|
||||
send(&mut app, Event::Input(second.clone(), "23".into()));
|
||||
send(&mut app, RowEvent::Input(first.clone(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(second.clone(), "23".into()));
|
||||
send(
|
||||
&mut app,
|
||||
Event::Append(default.clone(), json!({"Integer": 3})),
|
||||
RowEvent::Append(default.clone(), json!({"Integer": 3})),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
|
||||
assert_eq!(
|
||||
@@ -198,14 +200,14 @@ fn scoped_literal_list_edits_preserve_unfinished_values_and_other_facet_buffers(
|
||||
// Same-variant reselection must not throw away the invalid authored value.
|
||||
send(
|
||||
&mut app,
|
||||
Event::Variant(format!("{default}/0"), json!({"Integer": 0})),
|
||||
RowEvent::Variant(format!("{default}/0"), json!({"Integer": 0})),
|
||||
);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "-");
|
||||
send(&mut app, Event::Array(default.clone(), Some(0), 1));
|
||||
send(&mut app, RowEvent::Array(default.clone(), Some(0), 1));
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&first].raw, "23");
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[&second].raw, "-");
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Array(default, Some(1), 0));
|
||||
send(&mut app, RowEvent::Array(default, Some(1), 0));
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
assert_eq!(
|
||||
state(&app).draft.as_ref().unwrap().inputs[&minimum].raw,
|
||||
@@ -263,8 +265,8 @@ fn independent_review_keyed_fact_titles_keep_live_deleted_and_qualified_identity
|
||||
assert_ne!(super::view::title(&p, &annotation(vec![])), first_title);
|
||||
}
|
||||
pub(super) fn decide(app: &mut App, selected: FacetSelector, decision: SemanticDecision) {
|
||||
send(app, Event::Select(selected.clone()));
|
||||
send(app, Event::Decide(selected, Some(decision)));
|
||||
send(app, RowEvent::Select(selected.clone()));
|
||||
send(app, RowEvent::Decide(selected, Some(decision)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -304,7 +306,7 @@ fn scalar_keep_take_candidate_commit_exact_source_and_history_in_both_directions
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()),
|
||||
RowEvent::Input(format!("{prefix}/unit"), "kg\n exact suffix ".into()),
|
||||
);
|
||||
let prepared =
|
||||
prepare_semantic_transition(app.project(), state(&app).plan.as_ref().unwrap())
|
||||
@@ -368,9 +370,9 @@ fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
let path = "/body/Sa/Shared/DataType/definition/Integer/minimum";
|
||||
send(&mut app, Event::Input(path.into(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(path.into(), "-".into()));
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::Tab(Tab::References));
|
||||
send(&mut app, RowEvent::Tab(Tab::References));
|
||||
assert!(
|
||||
state(&app).error.is_some(),
|
||||
"Navigation must retain visible validation errors"
|
||||
@@ -378,8 +380,11 @@ fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
|
||||
apply(&mut app);
|
||||
assert_eq!(state(&app).draft.as_ref().unwrap().inputs[path].raw, "-");
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
send(&mut app, Event::Input(path.into(), "2".into()));
|
||||
send(&mut app, Event::Input("/alias".into(), "unexpected".into()));
|
||||
send(&mut app, RowEvent::Input(path.into(), "2".into()));
|
||||
send(
|
||||
&mut app,
|
||||
RowEvent::Input("/alias".into(), "unexpected".into()),
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
assert!(
|
||||
state(&app)
|
||||
@@ -389,7 +394,7 @@ fn invalid_numeric_draft_and_protected_edits_retain_workflow_and_source() {
|
||||
.contains("selected review")
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
send(&mut app, Event::Preview);
|
||||
send(&mut app, RowEvent::Preview);
|
||||
assert!(state(&app).can_apply());
|
||||
}
|
||||
|
||||
@@ -404,7 +409,7 @@ fn explicit_trace_selection_updates_only_that_history() {
|
||||
assert!(!state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::Trace(Choice {
|
||||
RowEvent::Trace(Choice {
|
||||
id: second,
|
||||
label: "Independent assessment".into(),
|
||||
}),
|
||||
@@ -469,7 +474,7 @@ fn stale_plan_and_source_writeback_failure_do_not_lose_decisions_or_history() {
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
RowEvent::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
"long final unit ".repeat(40),
|
||||
),
|
||||
@@ -517,19 +522,19 @@ fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessi
|
||||
assert!(!state(&app).can_apply());
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceOa(Choice {
|
||||
RowEvent::ReferenceOa(Choice {
|
||||
id: parent_oa,
|
||||
label: "OA parent".into(),
|
||||
}),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceSa(Choice {
|
||||
RowEvent::ReferenceSa(Choice {
|
||||
id: parent_sa,
|
||||
label: "SA parent".into(),
|
||||
}),
|
||||
);
|
||||
send(&mut app, Event::AddReference);
|
||||
send(&mut app, RowEvent::AddReference);
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::Unit),
|
||||
@@ -537,7 +542,7 @@ fn candidate_mixed_with_reference_take_uses_typed_correspondence_not_text_guessi
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
RowEvent::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
parent_oa.to_string(),
|
||||
),
|
||||
@@ -567,7 +572,7 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() {
|
||||
start(&mut app, oa);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Decide(
|
||||
RowEvent::Decide(
|
||||
FacetSelector::Literal(KeyPair {
|
||||
oa: Some("Unpaired".into()),
|
||||
sa: None,
|
||||
@@ -594,7 +599,7 @@ fn read_only_structured_facts_and_partial_rollup_are_honest() {
|
||||
let mut request = State::new(app.project(), trace).unwrap();
|
||||
request.update(
|
||||
app.project(),
|
||||
Event::Select(facet(DataFacet::IntegerMaximum)),
|
||||
RowEvent::Select(facet(DataFacet::IntegerMaximum)),
|
||||
);
|
||||
let _ = request.view(app.project());
|
||||
assert_eq!(
|
||||
@@ -630,7 +635,7 @@ fn first_equal_constructor_acknowledges_only_kind_and_allows_independent_leaves(
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()),
|
||||
RowEvent::Input("/body/Sa/Shared/DataType/unit".into(), "local unit".into()),
|
||||
);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
@@ -709,7 +714,7 @@ fn state_kind_scalar_uses_typed_candidate_and_core_validation() {
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")),
|
||||
RowEvent::Shape("/body/Sa/Shared/State/kind".into(), json!("Final")),
|
||||
);
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
@@ -769,7 +774,7 @@ fn relation_candidates_preserve_endpoints_and_combine_selected_roles_and_bounds(
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Shape("/body/DataAssociation/target_maximum".into(), json!(4)),
|
||||
RowEvent::Shape("/body/DataAssociation/target_maximum".into(), json!(4)),
|
||||
);
|
||||
assert!(state(&app).can_apply(), "{:?}", state(&app).error);
|
||||
apply(&mut app);
|
||||
@@ -802,7 +807,7 @@ fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
SemanticDecision::Candidate,
|
||||
);
|
||||
send(&mut app, Event::Input(path.into(), "-".into()));
|
||||
send(&mut app, RowEvent::Input(path.into(), "-".into()));
|
||||
decide(
|
||||
&mut app,
|
||||
facet(DataFacet::IntegerMinimum),
|
||||
@@ -815,7 +820,7 @@ fn deselected_invalid_candidate_is_retained_but_does_not_poison_another_selected
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Input(
|
||||
RowEvent::Input(
|
||||
"/body/Sa/Shared/DataType/unit".into(),
|
||||
"reviewed local".into(),
|
||||
),
|
||||
@@ -948,7 +953,7 @@ fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approva
|
||||
assert_eq!(impacts.len(), 1);
|
||||
assert_eq!(impacts[0].object, message);
|
||||
let exact = impacts[0].clone();
|
||||
send(&mut app, Event::AcceptImpact(0, true));
|
||||
send(&mut app, RowEvent::AcceptImpact(0, true));
|
||||
assert!(
|
||||
state(&app).can_apply(),
|
||||
"{:?}",
|
||||
@@ -973,7 +978,7 @@ fn inherited_annotation_impact_requires_exact_acceptance_and_edits_clear_approva
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!state(&app).can_apply());
|
||||
send(&mut app, Event::AcceptImpact(0, true));
|
||||
send(&mut app, RowEvent::AcceptImpact(0, true));
|
||||
let expected = state(&app).proposed.clone().unwrap();
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
apply(&mut app);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::row::form_event;
|
||||
use super::*;
|
||||
use crate::{style, views::short};
|
||||
use iced::{
|
||||
@@ -7,10 +8,17 @@ use iced::{
|
||||
},
|
||||
};
|
||||
|
||||
fn event(event: Event) -> Message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(event))
|
||||
#[cfg(test)]
|
||||
#[path = "view_reserved_artifact_tests.rs"]
|
||||
mod reserved_artifact_tests;
|
||||
|
||||
fn event(event: RowEvent) -> Message {
|
||||
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
|
||||
}
|
||||
fn action<'a>(label: impl Into<String>, event_value: Event) -> iced::widget::Button<'a, Message> {
|
||||
fn action<'a>(
|
||||
label: impl Into<String>,
|
||||
event_value: RowEvent,
|
||||
) -> iced::widget::Button<'a, Message> {
|
||||
button(text(label.into()).size(12))
|
||||
.padding([7, 9])
|
||||
.style(style::nav)
|
||||
@@ -22,7 +30,8 @@ pub(super) fn choice(project: &Project, id: Id) -> Choice {
|
||||
.get(&id)
|
||||
.map(|v| v.alias.as_str())
|
||||
.or_else(|| project.relations.get(&id).map(|v| v.alias.as_str()))
|
||||
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()));
|
||||
.or_else(|| project.artifact_links.get(&id).map(|v| v.alias.as_str()))
|
||||
.or_else(|| project.traces.get(&id).map(|v| v.alias.as_str()));
|
||||
Choice {
|
||||
id,
|
||||
label: alias.map_or_else(
|
||||
@@ -51,6 +60,7 @@ pub(super) fn group(facet: &FacetSelector) -> &'static str {
|
||||
FacetSelector::Data(_) | FacetSelector::Field(_) | FacetSelector::Literal(_) => {
|
||||
"Data and defaults"
|
||||
}
|
||||
FacetSelector::Category(_) => "Exchange categories",
|
||||
FacetSelector::Port(_) | FacetSelector::Interface(_) | FacetSelector::ExchangeItem(_) => {
|
||||
"Contracts and interfaces"
|
||||
}
|
||||
@@ -236,45 +246,99 @@ pub(super) fn observed(project: &Project, fact: Option<&FacetReview>, layer: Lay
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
||||
let mut traces: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == self.anchor || t.sa == self.anchor)
|
||||
.map(|t| Choice {
|
||||
id: t.id,
|
||||
label: format!(
|
||||
"{} · {:?} · {} ↔ {} · @{}",
|
||||
t.name,
|
||||
t.kind,
|
||||
project.named(t.oa),
|
||||
project.named(t.sa),
|
||||
t.alias
|
||||
),
|
||||
})
|
||||
.collect();
|
||||
traces.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
let selected_trace = traces.iter().find(|c| Some(c.id) == self.trace).cloned();
|
||||
let direction = if self.direction == TransitionDirection::OaToSa {
|
||||
"Use Operational changes in System"
|
||||
impl ReviewRow {
|
||||
pub(super) fn review_title(&self, project: &Project, facet: &FacetSelector) -> String {
|
||||
if let FacetSelector::Artifact { keys, facet } = facet {
|
||||
let label = |id: Option<Id>| {
|
||||
id.map(|id| self.review_choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into())
|
||||
};
|
||||
format!(
|
||||
"Artifact {} · {} ↔ {}",
|
||||
words(&format!("{facet:?}")),
|
||||
label(keys.oa),
|
||||
label(keys.sa)
|
||||
)
|
||||
} else {
|
||||
"Use System changes in Operational"
|
||||
};
|
||||
title(project, facet)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn review_choice(&self, project: &Project, id: Id) -> Choice {
|
||||
self.artifacts.choice(project, id)
|
||||
}
|
||||
|
||||
pub(super) fn view<'a>(
|
||||
&'a self,
|
||||
project: &'a Project,
|
||||
header: Element<'a, Message>,
|
||||
can_apply: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let trace = self.trace.and_then(|id| project.traces.get(&id));
|
||||
let mut content = column![
|
||||
text("Review semantics").size(22),
|
||||
header,
|
||||
row![
|
||||
pick_list(traces, selected_trace, |c| event(Event::Trace(c))).placeholder("Choose a trace / review history").text_size(12).padding(7).width(Fill),
|
||||
action(direction, Event::Direction(if self.direction == TransitionDirection::OaToSa { TransitionDirection::SaToOa } else { TransitionDirection::OaToSa })),
|
||||
].spacing(8),
|
||||
text("Changing trace or direction starts a new review draft. Other pending edits remain separate.").size(10).color(style::MUTED),
|
||||
row![action("Properties", Event::Tab(Tab::Facts)), action("Members / rows", Event::Tab(Tab::Members)), action(format!("History ({})", self.resets.len()), Event::Tab(Tab::History)), action(format!("Reference pairs ({})", self.references.len()), Event::Tab(Tab::References)), action(format!("Additional changes ({})", self.plan.as_ref().map_or(0, |p| p.impacts.len())), Event::Tab(Tab::Impacts)), Space::new().width(Fill), action("Refresh preview", Event::Preview)].spacing(6).wrap(),
|
||||
].spacing(8);
|
||||
action("Properties", RowEvent::Tab(Tab::Facts)),
|
||||
action("Members / rows", RowEvent::Tab(Tab::Members)),
|
||||
action("Attachments", RowEvent::Tab(Tab::Attachments)),
|
||||
action("Artifact links", RowEvent::Tab(Tab::Artifacts)),
|
||||
action(
|
||||
format!(
|
||||
"History ({}) · {} resets",
|
||||
trace
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
.map_or(0, |b| b.facets.len()),
|
||||
self.resets.len()
|
||||
),
|
||||
RowEvent::Tab(Tab::History)
|
||||
),
|
||||
action(
|
||||
format!("Reference pairs ({})", self.references.len()),
|
||||
RowEvent::Tab(Tab::References)
|
||||
),
|
||||
action(
|
||||
format!(
|
||||
"Additional changes ({})",
|
||||
self.plan.as_ref().map_or(0, |p| p.impacts.len())
|
||||
),
|
||||
RowEvent::Tab(Tab::Impacts)
|
||||
),
|
||||
Space::new().width(Fill),
|
||||
action("Refresh preview", RowEvent::Preview)
|
||||
]
|
||||
.spacing(6)
|
||||
.wrap(),
|
||||
]
|
||||
.spacing(8);
|
||||
let body = match self.tab {
|
||||
Tab::Facts => self.facts_view(project),
|
||||
Tab::References => self.references_view(project),
|
||||
Tab::Members => self.members_view(project),
|
||||
Tab::Artifacts => {
|
||||
if let Some(trace) = trace {
|
||||
scrollable(
|
||||
artifacts_view::pairs(&self.artifacts, project, trace)
|
||||
.map(|e| event(RowEvent::Artifact(e))),
|
||||
)
|
||||
.height(Fill)
|
||||
.into()
|
||||
} else {
|
||||
text("Choose a trace first.").into()
|
||||
}
|
||||
}
|
||||
Tab::Attachments => {
|
||||
if let Some(trace) = trace {
|
||||
scrollable(
|
||||
attachments_view::pairs(&self.attachments, project, trace)
|
||||
.map(|e| event(RowEvent::Attachment(e))),
|
||||
)
|
||||
.height(Fill)
|
||||
.into()
|
||||
} else {
|
||||
text("Choose a trace first.").into()
|
||||
}
|
||||
}
|
||||
Tab::History => self.history_view(project),
|
||||
Tab::Impacts => self.impacts_view(project),
|
||||
};
|
||||
@@ -300,7 +364,15 @@ impl State {
|
||||
.iter()
|
||||
.filter(|f| f.drift == Drift::Unreviewed)
|
||||
.count();
|
||||
content = content.push(text(format!("{} decisions · {} exact resets · {unreviewed} unreviewed facts · Attached-record and multi-source decisions remain pending.", self.decisions.len(), self.resets.len())).size(10).color(style::MUTED));
|
||||
content = content.push(
|
||||
text(format!(
|
||||
"{} decisions · {} exact resets · {unreviewed} unreviewed facts",
|
||||
self.decisions.len(),
|
||||
self.resets.len()
|
||||
))
|
||||
.size(10)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
if let Some(trace) = trace {
|
||||
content = content.push(text(summary(project, trace)).size(10).color(style::MUTED));
|
||||
}
|
||||
@@ -308,7 +380,7 @@ impl State {
|
||||
.push(
|
||||
row![
|
||||
text(
|
||||
"Apply updates the selected history and model together; Undo restores both."
|
||||
"Apply updates all included histories and the model once; Undo restores the whole batch."
|
||||
)
|
||||
.size(10)
|
||||
.width(Fill),
|
||||
@@ -319,7 +391,7 @@ impl State {
|
||||
button(text("Apply reviewed changes").size(12))
|
||||
.padding(8)
|
||||
.on_press_maybe(
|
||||
self.can_apply()
|
||||
can_apply
|
||||
.then_some(Message::Workflow(crate::workflow::Event::Apply))
|
||||
),
|
||||
]
|
||||
@@ -334,7 +406,7 @@ impl State {
|
||||
checkbox(self.show_unchanged)
|
||||
.label("Include unchanged facts")
|
||||
.text_size(11)
|
||||
.on_toggle(|v| event(Event::ShowUnchanged(v)))
|
||||
.on_toggle(|v| event(RowEvent::ShowUnchanged(v)))
|
||||
]
|
||||
.spacing(5);
|
||||
let mut groups: BTreeMap<&str, Vec<&FacetDrift>> = BTreeMap::new();
|
||||
@@ -359,7 +431,7 @@ impl State {
|
||||
list = list.push(
|
||||
button(
|
||||
column![
|
||||
text(short(&title(project, &fact.selector), 52)).size(12),
|
||||
text(short(&self.review_title(project, &fact.selector), 52)).size(12),
|
||||
text(format!("{mark}{}", drift(fact.drift))).size(10)
|
||||
]
|
||||
.spacing(3),
|
||||
@@ -371,13 +443,17 @@ impl State {
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Select(fact.selector.clone()))),
|
||||
.on_press(event(RowEvent::Select(fact.selector.clone()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
row![
|
||||
scrollable(list).height(305).width(260),
|
||||
scrollable(self.detail(project)).height(305).width(Fill)
|
||||
scrollable(list.padding(iced::Padding::default().right(18)))
|
||||
.height(305)
|
||||
.width(260),
|
||||
scrollable(container(self.detail(project)).padding(iced::Padding::default().right(18)))
|
||||
.height(305)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(12)
|
||||
.into()
|
||||
@@ -406,7 +482,7 @@ impl State {
|
||||
.map(|f| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
title(project, &f.selector()),
|
||||
self.review_title(project, &f.selector()),
|
||||
observed(project, Some(f), layer)
|
||||
)
|
||||
})
|
||||
@@ -455,7 +531,7 @@ impl State {
|
||||
.width(Fill)
|
||||
};
|
||||
let mut detail = column![
|
||||
text(title(project, &fact.selector)).size(17),
|
||||
text(self.review_title(project, &fact.selector)).size(17),
|
||||
text(drift(fact.drift)).size(11),
|
||||
row![side(Layer::Oa, "Operational"), side(Layer::Sa, "System")].spacing(14)
|
||||
]
|
||||
@@ -466,12 +542,15 @@ impl State {
|
||||
checkbox(self.resets.contains(&facet))
|
||||
.label("Reset this exact previous observation before review")
|
||||
.text_size(11)
|
||||
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
||||
.on_toggle(move |v| event(RowEvent::Reset(facet.clone(), v))),
|
||||
);
|
||||
}
|
||||
if keyed::supported(&fact.selector) {
|
||||
if keyed::supported(&fact.selector)
|
||||
|| attachments::supported(&fact.selector)
|
||||
|| artifacts::supported(&fact.selector)
|
||||
{
|
||||
detail = detail.push(text("Review changes only this collection entry; referenced objects stay intact. Take preserves a row with no source identity. An explicit source identity that is absent can remove its mapped destination entry. Changing a row key requires separate removal and addition.").size(11))
|
||||
.push(action("Choose / change exact row identities…", Event::EditPair(fact.selector.clone())));
|
||||
.push(action("Choose / change exact row identities…", RowEvent::EditPair(fact.selector.clone())));
|
||||
if let Some(baseline) = trace
|
||||
.and_then(|t| t.baseline.as_ref())
|
||||
.and_then(|b| b.semantic.as_ref())
|
||||
@@ -483,10 +562,10 @@ impl State {
|
||||
checkbox(self.resets.contains(&prior))
|
||||
.label(format!(
|
||||
"Reset overlapping history: {}",
|
||||
title(project, &prior)
|
||||
self.review_title(project, &prior)
|
||||
))
|
||||
.text_size(11)
|
||||
.on_toggle(move |v| event(Event::Reset(prior.clone(), v))),
|
||||
.on_toggle(move |v| event(RowEvent::Reset(prior.clone(), v))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -553,25 +632,33 @@ impl State {
|
||||
let mut buttons = row![
|
||||
action(
|
||||
format!("Keep {destination}"),
|
||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
|
||||
RowEvent::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))
|
||||
),
|
||||
action(
|
||||
format!("Take {source}"),
|
||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
|
||||
RowEvent::Decide(fact.selector.clone(), Some(SemanticDecision::TakeSource))
|
||||
)
|
||||
]
|
||||
.spacing(5);
|
||||
buttons = buttons.push(action(
|
||||
if is_group {
|
||||
"Set final kind and payload…"
|
||||
} else {
|
||||
"Set final value…"
|
||||
},
|
||||
Event::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
||||
));
|
||||
if !matches!(
|
||||
fact.selector,
|
||||
FacetSelector::Artifact {
|
||||
facet: ArtifactFacet::Presence,
|
||||
..
|
||||
}
|
||||
) {
|
||||
buttons = buttons.push(action(
|
||||
if is_group {
|
||||
"Set final kind and payload…"
|
||||
} else {
|
||||
"Set final value…"
|
||||
},
|
||||
RowEvent::Decide(fact.selector.clone(), Some(SemanticDecision::Candidate)),
|
||||
));
|
||||
}
|
||||
detail = detail.push(buttons.wrap()).push(action(
|
||||
"Leave unselected",
|
||||
Event::Decide(fact.selector.clone(), None),
|
||||
RowEvent::Decide(fact.selector.clone(), None),
|
||||
));
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& keyed::supported(&fact.selector)
|
||||
@@ -583,7 +670,7 @@ impl State {
|
||||
"Include in destination collection (unchecked removes only this entry)",
|
||||
)
|
||||
.text_size(12)
|
||||
.on_toggle(move |v| event(Event::Presence(facet.clone(), v))),
|
||||
.on_toggle(move |v| event(RowEvent::Presence(facet.clone(), v))),
|
||||
);
|
||||
if keyed_rows::supported(&fact.selector)
|
||||
&& let Some(draft) = self.row_drafts.get(&fact.selector)
|
||||
@@ -596,7 +683,7 @@ impl State {
|
||||
iced::widget::text_editor(&draft.editor)
|
||||
.placeholder("Expression")
|
||||
.on_action(move |action| {
|
||||
event(Event::RowText(facet.clone(), action))
|
||||
event(RowEvent::RowText(facet.clone(), action))
|
||||
})
|
||||
.size(12)
|
||||
.height(90)
|
||||
@@ -612,7 +699,7 @@ impl State {
|
||||
false,
|
||||
),
|
||||
draft.local.clone(),
|
||||
move |value| event(Event::RowLocal(facet.clone(), value)),
|
||||
move |value| event(RowEvent::RowLocal(facet.clone(), value)),
|
||||
)
|
||||
.placeholder("Local lifeline in destination scenario")
|
||||
.text_size(12)
|
||||
@@ -625,6 +712,27 @@ impl State {
|
||||
}
|
||||
}
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& let Some(editor) = attachments_view::candidate(
|
||||
&self.attachments,
|
||||
project,
|
||||
&fact.selector,
|
||||
self.direction.target_layer(),
|
||||
)
|
||||
{
|
||||
detail = detail.push(editor.map(form_event));
|
||||
}
|
||||
if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& let Some(editor) =
|
||||
artifacts_view::candidate(&self.artifacts, project, &fact.selector)
|
||||
{
|
||||
detail = detail.push(editor.map(|e| event(RowEvent::Artifact(e))));
|
||||
}
|
||||
if artifacts::supported(&fact.selector) {
|
||||
detail = detail.push(text("Metadata is reviewed from this initially attached owner. Owner moves and source-absence deletion must agree with kept Presence. Creating a reserved counterpart requires Presence plus all seven metadata decisions; each raw draft remains independent.").size(11));
|
||||
}
|
||||
if !attachments::supported(&fact.selector)
|
||||
&& !artifacts::supported(&fact.selector)
|
||||
&& self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate)
|
||||
&& let Some(draft) = self.candidate_draft(&fact.selector)
|
||||
&& let Some(paths) = self
|
||||
.target(project)
|
||||
@@ -652,15 +760,15 @@ impl State {
|
||||
}
|
||||
} else if self.can_acknowledge_constructor(project, &fact.selector) {
|
||||
detail = detail.push(text("Both constructor kinds are equal and have no prior observation. Acknowledge only the kind; its properties remain independent.").size(12))
|
||||
.push(action("Acknowledge equal kind only", Event::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
||||
.push(action("Leave unselected", Event::Decide(fact.selector.clone(), None)));
|
||||
.push(action("Acknowledge equal kind only", RowEvent::Decide(fact.selector.clone(), Some(SemanticDecision::KeepTarget))))
|
||||
.push(action("Leave unselected", RowEvent::Decide(fact.selector.clone(), None)));
|
||||
} else {
|
||||
detail = detail.push(text(if scalar::grouped(&fact.selector) { "This constructor is unavailable on one of the current counterparts. Review the outer constructor before its nested kind." } else { "This structured or inapplicable fact is read-only here. Its review history is retained." }).size(12).color(style::MUTED));
|
||||
}
|
||||
if self.staged.contains(&fact.selector) {
|
||||
detail = detail.push(action(
|
||||
"Remove staged address and its decision",
|
||||
Event::DropStaged(fact.selector.clone()),
|
||||
RowEvent::DropStaged(fact.selector.clone()),
|
||||
));
|
||||
}
|
||||
let overlaps: Vec<_> = self
|
||||
@@ -677,22 +785,31 @@ impl State {
|
||||
detail = detail.push(text("Related decisions are selected. Paired rows cannot review the same OA or SA identity twice. Replacing groups require one combined decision; first equal-kind acknowledgments may coexist with descendants. Resolve overlaps explicitly; all drafts are retained.").size(11));
|
||||
for other in overlaps {
|
||||
detail = detail.push(action(
|
||||
format!("Leave {} unselected", title(project, other)),
|
||||
Event::Decide(other.clone(), None),
|
||||
format!("Leave {} unselected", self.review_title(project, other)),
|
||||
RowEvent::Decide(other.clone(), None),
|
||||
));
|
||||
}
|
||||
detail = detail.push(action(
|
||||
"Leave this decision unselected; use the related decisions",
|
||||
Event::Decide(fact.selector.clone(), None),
|
||||
RowEvent::Decide(fact.selector.clone(), None),
|
||||
));
|
||||
}
|
||||
if (is_group
|
||||
|| keyed::supported(&fact.selector)
|
||||
|| artifacts::supported(&fact.selector)
|
||||
|| matches!(fact.selector, FacetSelector::Order(_))
|
||||
|| constructors::selectors().iter().any(|group| {
|
||||
self.decisions.contains_key(group) && constructors::contains(group, &fact.selector)
|
||||
}))
|
||||
&& let Some(review) = self.plan.as_ref().and_then(|p| p.reviews.first())
|
||||
&& let Some(review) = self.plan.as_ref().and_then(|plan| {
|
||||
let trace = project.traces.get(&self.trace?)?;
|
||||
let source = if self.direction == TransitionDirection::OaToSa {
|
||||
trace.oa
|
||||
} else {
|
||||
trace.sa
|
||||
};
|
||||
plan.reviews.iter().find(|review| review.source == source)
|
||||
})
|
||||
{
|
||||
detail = detail.push(text("Exact review scope from preview").size(13));
|
||||
for (label, selectors) in [
|
||||
@@ -707,7 +824,7 @@ impl State {
|
||||
} else {
|
||||
selectors
|
||||
.iter()
|
||||
.map(|s| title(project, s))
|
||||
.map(|s| self.review_title(project, s))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
@@ -733,13 +850,13 @@ impl State {
|
||||
let mut content = column![
|
||||
text("Review an individual member or row").size(16),
|
||||
text("Choose an exact address, then review its value and presence in Properties. This also stages a new row in an empty collection. No model object is created.").size(12),
|
||||
pick_list(families, Some(self.member_family), |v| event(Event::MemberFamily(v))).text_size(12).padding(7),
|
||||
pick_list(families, Some(self.member_family), |v| event(RowEvent::MemberFamily(v))).text_size(12).padding(7),
|
||||
].spacing(9);
|
||||
if let keyed::Family::Member(collection) = self.member_family
|
||||
&& let Some(which) = ordered::ordered(collection)
|
||||
{
|
||||
content = content.push(text("Membership and order are separate decisions. An incoming member with ambiguous placement requires an explicit final order; referenced objects are not created or reparented here.").size(11))
|
||||
.push(action("Review final order…", Event::Select(FacetSelector::Order(which))));
|
||||
.push(action("Review final order…", RowEvent::Select(FacetSelector::Order(which))));
|
||||
}
|
||||
if !available {
|
||||
content = content.push(text("This collection is not available on the current counterparts. Choose an available collection above; existing staged addresses remain removable below.").size(12));
|
||||
@@ -749,31 +866,33 @@ impl State {
|
||||
.push(self.annotation_address_view(project, Layer::Sa));
|
||||
} else if self.member_family == keyed::Family::Literal {
|
||||
content = content.push(text("Literal identity is exact, including case and spaces. Both sides use the same string; a rename requires separate old/new rows.").size(11))
|
||||
.push(text_input("Exact literal", &self.literal).on_input(|v| event(Event::Literal(v))).size(12).padding(7))
|
||||
.push(row![checkbox(self.literal_oa).label("Operational identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Oa,v))), checkbox(self.literal_sa).label("System identity").text_size(12).on_toggle(|v| event(Event::LiteralSide(Layer::Sa,v)))].spacing(12));
|
||||
.push(text_input("Exact literal", &self.literal).on_input(|v| event(RowEvent::Literal(v))).size(12).padding(7))
|
||||
.push(row![checkbox(self.literal_oa).label("Operational identity").text_size(12).on_toggle(|v| event(RowEvent::LiteralSide(Layer::Oa,v))), checkbox(self.literal_sa).label("System identity").text_size(12).on_toggle(|v| event(RowEvent::LiteralSide(Layer::Sa,v)))].spacing(12));
|
||||
} else {
|
||||
content = content.push(text("Identities may exist outside the collection. Choosing both explicitly sets their OA → SA correspondence. No identity means an unpaired side, distinct from an absent member.").size(11))
|
||||
.push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(Event::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", Event::MemberOa(None))].spacing(7))
|
||||
.push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(Event::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", Event::MemberSa(None))].spacing(7));
|
||||
.push(row![pick_list(self.member_choices(project, Layer::Oa), self.member_oa.clone(), |v| event(RowEvent::MemberOa(Some(v)))).placeholder("Operational identity").text_size(12).padding(7).width(Fill), action("No OA identity", RowEvent::MemberOa(None))].spacing(7))
|
||||
.push(row![pick_list(self.member_choices(project, Layer::Sa), self.member_sa.clone(), |v| event(RowEvent::MemberSa(Some(v)))).placeholder("System identity").text_size(12).padding(7).width(Fill), action("No SA identity", RowEvent::MemberSa(None))].spacing(7));
|
||||
}
|
||||
content = content.push(button(text("Use these addresses and review row").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(Event::StageMember))))
|
||||
content = content.push(button(text("Use these addresses and review row").size(12)).padding([7,9]).style(style::nav).on_press_maybe(available.then_some(event(RowEvent::StageMember))))
|
||||
.push(text("Changing a row address retains its earlier decisions and drafts. Reset conflicting historical observations and leave overlapping decisions unselected explicitly.").size(11).color(style::MUTED));
|
||||
if !self.staged.is_empty() {
|
||||
content = content.push(text("Staged addresses in this draft").size(13));
|
||||
for facet in &self.staged {
|
||||
content = content.push(
|
||||
row![
|
||||
text(title(project, facet)).size(11).width(Fill),
|
||||
text(self.review_title(project, facet)).size(11).width(Fill),
|
||||
action(
|
||||
"Remove address / decision",
|
||||
Event::DropStaged(facet.clone())
|
||||
RowEvent::DropStaged(facet.clone())
|
||||
)
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content).height(305).into()
|
||||
scrollable(content.padding(iced::Padding::default().right(18)))
|
||||
.height(305)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn order_view<'a>(
|
||||
@@ -783,23 +902,27 @@ impl State {
|
||||
) -> Element<'a, Message> {
|
||||
let mut content = column![text("Final destination order").size(13),
|
||||
text("Up/Down changes only positions. Reconcile members explicitly after changing presence: retain surviving positions, remove excluded entries and append newly included entries.").size(11),
|
||||
action("Reconcile with selected membership", Event::OrderReconcile(which))].spacing(6);
|
||||
action("Reconcile with selected membership", RowEvent::OrderReconcile(which))].spacing(6);
|
||||
if let Some(ids) = self.order_drafts.get(&which) {
|
||||
if ids.is_empty() {
|
||||
content = content.push(text("Empty order").size(12));
|
||||
}
|
||||
for (index, id) in ids.iter().enumerate() {
|
||||
let up = button(text("Up").size(11)).padding(5).on_press_maybe(
|
||||
(index > 0).then_some(event(Event::OrderMove(which, index, -1))),
|
||||
(index > 0).then_some(event(RowEvent::OrderMove(which, index, -1))),
|
||||
);
|
||||
let down = button(text("Down").size(11)).padding(5).on_press_maybe(
|
||||
(index + 1 < ids.len()).then_some(event(Event::OrderMove(which, index, 1))),
|
||||
(index + 1 < ids.len()).then_some(event(RowEvent::OrderMove(which, index, 1))),
|
||||
);
|
||||
content = content.push(
|
||||
row![
|
||||
text(format!("{}. {}", index + 1, choice(project, *id).label))
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
text(format!(
|
||||
"{}. {}",
|
||||
index + 1,
|
||||
self.review_choice(project, *id).label
|
||||
))
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
up,
|
||||
down
|
||||
]
|
||||
@@ -811,12 +934,12 @@ impl State {
|
||||
let missing: Vec<_> = members
|
||||
.iter()
|
||||
.filter(|id| !ids.contains(id))
|
||||
.map(|id| choice(project, *id).label)
|
||||
.map(|id| self.review_choice(project, *id).label)
|
||||
.collect();
|
||||
let excluded: Vec<_> = ids
|
||||
.iter()
|
||||
.filter(|id| !members.contains(id))
|
||||
.map(|id| choice(project, *id).label)
|
||||
.map(|id| self.review_choice(project, *id).label)
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
content = content.push(
|
||||
@@ -865,8 +988,8 @@ impl State {
|
||||
}
|
||||
content = content.push(
|
||||
row![
|
||||
action("Root fields", Event::AnnotationBrowse(layer, None, 0)),
|
||||
action("No identity", Event::AnnotationAddress(layer, None)),
|
||||
action("Root fields", RowEvent::AnnotationBrowse(layer, None, 0)),
|
||||
action("No identity", RowEvent::AnnotationAddress(layer, None)),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
@@ -887,7 +1010,7 @@ impl State {
|
||||
)
|
||||
.push(action(
|
||||
"Up",
|
||||
Event::AnnotationBrowse(layer, crate::data_values::parent(scope), 0),
|
||||
RowEvent::AnnotationBrowse(layer, crate::data_values::parent(scope), 0),
|
||||
));
|
||||
}
|
||||
match self.annotation_choices(project, layer) {
|
||||
@@ -907,7 +1030,7 @@ impl State {
|
||||
.cloned();
|
||||
content = content.push(
|
||||
pick_list(visible, selected, move |choice| {
|
||||
event(Event::AnnotationAddress(layer, Some(choice.key)))
|
||||
event(RowEvent::AnnotationAddress(layer, Some(choice.key)))
|
||||
})
|
||||
.placeholder("Choose a field")
|
||||
.text_size(12)
|
||||
@@ -917,7 +1040,7 @@ impl State {
|
||||
if let Some(selected) = &address.selected {
|
||||
content = content.push(action(
|
||||
"Browse fields within selected record",
|
||||
Event::AnnotationBrowse(layer, Some(selected.clone()), 0),
|
||||
RowEvent::AnnotationBrowse(layer, Some(selected.clone()), 0),
|
||||
));
|
||||
}
|
||||
let mut navigation =
|
||||
@@ -925,13 +1048,13 @@ impl State {
|
||||
if page > 0 {
|
||||
navigation = navigation.push(action(
|
||||
"Previous",
|
||||
Event::AnnotationBrowse(layer, address.scope.clone(), page - 1),
|
||||
RowEvent::AnnotationBrowse(layer, address.scope.clone(), page - 1),
|
||||
));
|
||||
}
|
||||
if page + 1 < pages {
|
||||
navigation = navigation.push(action(
|
||||
"Next",
|
||||
Event::AnnotationBrowse(layer, address.scope.clone(), page + 1),
|
||||
RowEvent::AnnotationBrowse(layer, address.scope.clone(), page + 1),
|
||||
));
|
||||
}
|
||||
content = content.push(navigation);
|
||||
@@ -951,11 +1074,12 @@ impl State {
|
||||
content =
|
||||
content.push(text("No previous semantic observations on this trace.").size(12));
|
||||
}
|
||||
content = content.push(action("Restore reviewed reference pairs", RowEvent::RestoreReviewedReferences)).push(text("Restores exact historical key pairs only. Ordered lists do not identify counterpart pairs. Pending resets are excluded.").size(11));
|
||||
if let Some(baseline) = baseline {
|
||||
for fact in &baseline.facets {
|
||||
let facet = fact.selector();
|
||||
let mut item = column![
|
||||
text(title(project, &facet)).size(13),
|
||||
text(self.review_title(project, &facet)).size(13),
|
||||
text(format!(
|
||||
"Operational: {}\nSystem: {}",
|
||||
observed(project, Some(fact), Layer::Oa),
|
||||
@@ -969,7 +1093,7 @@ impl State {
|
||||
checkbox(self.resets.contains(&facet))
|
||||
.label("Reset this exact observation")
|
||||
.text_size(11)
|
||||
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
||||
.on_toggle(move |v| event(RowEvent::Reset(facet.clone(), v))),
|
||||
);
|
||||
} else {
|
||||
item = item.push(
|
||||
@@ -987,25 +1111,27 @@ impl State {
|
||||
checkbox(true)
|
||||
.label(format!(
|
||||
"Pending reset no longer in current history: {}",
|
||||
title(project, &facet)
|
||||
self.review_title(project, &facet)
|
||||
))
|
||||
.text_size(11)
|
||||
.on_toggle(move |v| event(Event::Reset(facet.clone(), v))),
|
||||
.on_toggle(move |v| event(RowEvent::Reset(facet.clone(), v))),
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content).height(305).into()
|
||||
scrollable(content.padding(iced::Padding::default().right(18)))
|
||||
.height(305)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn references_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
let mut content = column![
|
||||
text("Reference correspondence · always Operational → System").size(15),
|
||||
text("Choose explicit counterpart identities before taking reference values. Names do not establish correspondence. Changing an already reviewed keyed pair requires an exact reset in History. Existing decisions are retained; resolve their overlapping row identities explicitly.").size(12),
|
||||
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(Event::ReferenceSearch(v))).padding(7).size(12),
|
||||
text_input("Filter reference choices by name or alias", &self.reference_search).on_input(|v| event(RowEvent::ReferenceSearch(v))).padding(7).size(12),
|
||||
row![
|
||||
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.clone(), |c| event(Event::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
||||
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.clone(), |c| event(Event::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
|
||||
action("Use pair", Event::AddReference),
|
||||
pick_list(self.all_references(project, Layer::Oa), self.reference_oa.as_ref().map(|c| self.review_choice(project, c.id)), |c| event(RowEvent::ReferenceOa(c))).placeholder("Operational reference").padding(7).text_size(12).width(Fill),
|
||||
pick_list(self.all_references(project, Layer::Sa), self.reference_sa.as_ref().map(|c| self.review_choice(project, c.id)), |c| event(RowEvent::ReferenceSa(c))).placeholder("System reference").padding(7).text_size(12).width(Fill),
|
||||
action("Use pair", RowEvent::AddReference),
|
||||
].spacing(7).width(Fill),
|
||||
].spacing(9).width(Fill);
|
||||
for (oa, sa) in &self.references {
|
||||
@@ -1013,12 +1139,12 @@ impl State {
|
||||
row![
|
||||
text(format!(
|
||||
"{}\n↔ {}",
|
||||
choice(project, *oa).label,
|
||||
choice(project, *sa).label
|
||||
self.review_choice(project, *oa).label,
|
||||
self.review_choice(project, *sa).label
|
||||
))
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
action("Remove pair", Event::RemoveReference(*oa))
|
||||
action("Remove pair", RowEvent::RemoveReference(*oa))
|
||||
]
|
||||
.spacing(6)
|
||||
.width(Fill),
|
||||
@@ -1036,12 +1162,12 @@ impl State {
|
||||
impact
|
||||
.objects
|
||||
.oa
|
||||
.map(|id| choice(project, id).label)
|
||||
.map(|id| self.review_choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
impact
|
||||
.objects
|
||||
.sa
|
||||
.map(|id| choice(project, id).label)
|
||||
.map(|id| self.review_choice(project, id).label)
|
||||
.unwrap_or_else(|| "Absent".into()),
|
||||
drift(impact.drift),
|
||||
if impact.unreviewed {
|
||||
@@ -1058,7 +1184,11 @@ impl State {
|
||||
Err(error) => content = content.push(text(error).size(12)),
|
||||
}
|
||||
}
|
||||
scrollable(content).width(Fill).height(305).into()
|
||||
// The vertical scrollbar overlays the content's right edge.
|
||||
scrollable(content.padding(iced::Padding::default().right(18)))
|
||||
.width(Fill)
|
||||
.height(305)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn impacts_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> {
|
||||
@@ -1084,7 +1214,7 @@ impl State {
|
||||
checkbox(self.accepted_impacts.contains(impact))
|
||||
.label(format!("Accept · {}", project.named(impact.object)))
|
||||
.text_size(12)
|
||||
.on_toggle(move |accepted| event(Event::AcceptImpact(
|
||||
.on_toggle(move |accepted| event(RowEvent::AcceptImpact(
|
||||
index, accepted
|
||||
))),
|
||||
text("Before").size(11).color(style::MUTED),
|
||||
@@ -1111,6 +1241,8 @@ impl State {
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content).height(305).into()
|
||||
scrollable(content.padding(iced::Padding::default().right(18)))
|
||||
.height(305)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use super::*;
|
||||
|
||||
fn send(state: &mut State, project: &Project, event: RowEvent) {
|
||||
let addressed = state.address_event(event);
|
||||
state.update(project, addressed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_artifact_facet_and_reference_labels_survive_recovery_without_calling_it_deleted() {
|
||||
for reverse in [false, true] {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Inspect equipment", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor equipment", None)
|
||||
.unwrap();
|
||||
let source_layer = if reverse { Layer::Sa } else { Layer::Oa };
|
||||
let source = Id::new_v4();
|
||||
p.artifact_links.insert(
|
||||
source,
|
||||
ArtifactLink {
|
||||
id: source,
|
||||
alias: "interlock_requirement".into(),
|
||||
name: "Interlock requirement".into(),
|
||||
description: String::new(),
|
||||
owner: if reverse { sa } else { oa },
|
||||
relation: ArtifactRelation::Satisfies,
|
||||
uri: "requirements/interlock".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let trace = super::super::tests::add_trace(&mut p, oa, sa, "Requirement correspondence");
|
||||
let accepted = p.clone();
|
||||
let mut state = State::new(&p, trace).unwrap();
|
||||
if reverse {
|
||||
state.update(&p, Event::Direction(TransitionDirection::SaToOa));
|
||||
}
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Pick(
|
||||
source_layer,
|
||||
Some(choice(&p, source)),
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut state,
|
||||
&p,
|
||||
RowEvent::Artifact(artifacts::Event::Reserve),
|
||||
);
|
||||
send(&mut state, &p, RowEvent::Artifact(artifacts::Event::Stage));
|
||||
let keys = state.artifacts.pair();
|
||||
let destination = if reverse {
|
||||
keys.oa.unwrap()
|
||||
} else {
|
||||
keys.sa.unwrap()
|
||||
};
|
||||
let seed = state.artifacts.seeds[&destination].clone();
|
||||
let expected = format!("Interlock requirement · reserved @{}", seed.alias);
|
||||
let stored = state.recovery_snapshot().unwrap();
|
||||
let restored = State::from_recovery(&p, stored.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), stored);
|
||||
for review in [&state, &restored] {
|
||||
// Header and list use the same row-aware title; reference pairs and
|
||||
// selected controls use the same row-aware identity resolver.
|
||||
for which in [
|
||||
ArtifactFacet::Presence,
|
||||
ArtifactFacet::Name,
|
||||
ArtifactFacet::Owner,
|
||||
] {
|
||||
let title = review.review_title(&p, &artifacts::selector(&keys, which));
|
||||
assert!(title.contains(&expected), "{title}");
|
||||
assert!(title.contains("@interlock_requirement"), "{title}");
|
||||
assert!(!title.contains("Deleted reference"), "{title}");
|
||||
}
|
||||
assert_eq!(review.review_choice(&p, destination).label, expected);
|
||||
assert!(
|
||||
review
|
||||
.references
|
||||
.iter()
|
||||
.any(|(oa, sa)| Some(*oa) == keys.oa && Some(*sa) == keys.sa)
|
||||
);
|
||||
let truly_missing = Id::new_v4();
|
||||
assert_eq!(
|
||||
review.review_choice(&p, truly_missing).label,
|
||||
format!("Deleted reference · {truly_missing}")
|
||||
);
|
||||
let missing_facet = artifacts::selector(
|
||||
&KeyPair {
|
||||
oa: Some(source),
|
||||
sa: Some(truly_missing),
|
||||
},
|
||||
ArtifactFacet::Presence,
|
||||
);
|
||||
assert!(
|
||||
review
|
||||
.review_title(&p, &missing_facet)
|
||||
.contains("Deleted reference")
|
||||
);
|
||||
}
|
||||
assert_eq!(p, accepted);
|
||||
assert!(!p.contains(destination));
|
||||
// A retained draft must not label an identity reserved after it has
|
||||
// become an accepted artifact; the accepted name now takes precedence.
|
||||
let mut created = p.clone();
|
||||
let mut accepted_link = seed;
|
||||
accepted_link.name = "Accepted interlock evidence".into();
|
||||
created.artifact_links.insert(destination, accepted_link);
|
||||
let label = restored.review_choice(&created, destination).label;
|
||||
assert!(label.contains("Accepted interlock evidence"), "{label}");
|
||||
assert!(
|
||||
!label.contains("reserved") && !label.contains("Deleted reference"),
|
||||
"{label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,22 @@ pub struct RecoveryWorkspace {
|
||||
}
|
||||
|
||||
impl RecoveryWorkspace {
|
||||
/// Refresh only accepted clean buffers. Dirty baselines and invalid/unapplied
|
||||
/// workspaces are authored recovery data, not migration input.
|
||||
pub(crate) fn upgrade_clean_buffers(&mut self, old: &SourceBundle, new: &SourceBundle) {
|
||||
if !self.applied || self.entry != old.entry {
|
||||
return;
|
||||
}
|
||||
for (path, (text, baseline)) in &mut self.files {
|
||||
if text == baseline
|
||||
&& old.files.get(path) == Some(text)
|
||||
&& let Some(upgraded) = new.files.get(path)
|
||||
{
|
||||
*text = upgraded.clone();
|
||||
*baseline = upgraded.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Check the same bounds before replacing a checkpoint and before restoring it.
|
||||
pub(crate) fn validate(&self) -> Result<(), String> {
|
||||
if self.files.len() > LIMITS.max_files
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
//! Duplicate presentation identities while retaining the same engineering model.
|
||||
use oa_core::{Command, Id, Project};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn fresh_id(used: &mut BTreeSet<Id>) -> Id {
|
||||
loop {
|
||||
let id = Id::new_v4();
|
||||
if used.insert(id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_alias(used: &mut BTreeSet<String>, original: &str) -> String {
|
||||
let stem = format!("{original}_copy");
|
||||
let mut candidate = stem.clone();
|
||||
let mut suffix = 2usize;
|
||||
while !used.insert(candidate.clone()) {
|
||||
candidate = format!("{stem}_{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// The returned existing core command creates the whole presentation atomically.
|
||||
/// It does not copy artifact claims or reidentify any existing model record.
|
||||
pub fn command(project: &Project, source: Id) -> Result<Command, String> {
|
||||
let mut diagram = project
|
||||
.diagrams
|
||||
.get(&source)
|
||||
.cloned()
|
||||
.ok_or("The saved view to duplicate no longer exists.")?;
|
||||
let identities = project.identities();
|
||||
let mut used_ids: BTreeSet<_> = identities.iter().map(|(id, _)| *id).collect();
|
||||
let mut used_aliases: BTreeSet<_> = identities
|
||||
.iter()
|
||||
.map(|(_, alias)| (*alias).to_owned())
|
||||
.collect();
|
||||
diagram.id = fresh_id(&mut used_ids);
|
||||
diagram.alias = fresh_alias(&mut used_aliases, &diagram.alias);
|
||||
diagram.name = format!("{} copy", diagram.name);
|
||||
diagram.nodes = diagram
|
||||
.nodes
|
||||
.into_values()
|
||||
.map(|mut node| {
|
||||
node.id = fresh_id(&mut used_ids);
|
||||
node.alias = fresh_alias(&mut used_aliases, &node.alias);
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
Ok(Command::CreateDiagram(diagram))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::{view_options::*, *};
|
||||
|
||||
fn assert_only_presentation_ids_changed(source: &Diagram, copy: &Diagram) {
|
||||
assert_ne!(source.id, copy.id);
|
||||
assert_ne!(source.alias, copy.alias);
|
||||
assert_eq!(copy.name, format!("{} copy", source.name));
|
||||
assert_eq!(copy.description, source.description);
|
||||
assert_eq!(copy.layer, source.layer);
|
||||
assert_eq!(copy.projection, source.projection);
|
||||
assert_eq!(copy.view_options, source.view_options);
|
||||
assert_eq!(copy.routes, source.routes);
|
||||
assert_eq!(copy.nodes.len(), source.nodes.len());
|
||||
for node in source.nodes.values() {
|
||||
let copied = copy
|
||||
.nodes
|
||||
.values()
|
||||
.find(|copy| copy.element == node.element)
|
||||
.unwrap();
|
||||
assert_ne!(node.id, copied.id);
|
||||
assert_ne!(node.alias, copied.alias);
|
||||
let mut normalized = copied.clone();
|
||||
normalized.id = node.id;
|
||||
normalized.alias.clone_from(&node.alias);
|
||||
assert_eq!(&normalized, node);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_retains_ports_routes_options_and_artifact_claims_with_exact_undo_redo() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Request", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Provide", None)
|
||||
.unwrap();
|
||||
let exchange = project.relate(RelationKind::FunctionalExchange, "Data", a, b);
|
||||
let port = match project.relations[&exchange].body {
|
||||
RelationBody::FunctionalExchange { source_port, .. } => source_port,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 120. });
|
||||
project.place(diagram, b, Position { x: 700., y: 240. });
|
||||
let view = project.diagrams.get_mut(&diagram).unwrap();
|
||||
view.description = "Engineering concern with manual routing".into();
|
||||
view.projection = ProjectionKind::FunctionalDataflow;
|
||||
view.nodes
|
||||
.get_mut(&first)
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.35,
|
||||
});
|
||||
view.routes.push(EdgeRoute {
|
||||
edge: exchange,
|
||||
waypoints: vec![Position { x: 420., y: 440. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: Position { x: 8., y: -15. },
|
||||
}),
|
||||
});
|
||||
view.view_options = ViewOptions {
|
||||
hidden_edges: vec![exchange],
|
||||
work_categories: CategorySelection::Any(vec![]),
|
||||
work_contexts: ContextSelection::Any(vec![]),
|
||||
exchange_labels: ExchangeLabels::NameAndItems,
|
||||
..ViewOptions::default()
|
||||
};
|
||||
let artifact = Id::new_v4();
|
||||
project.artifact_links.insert(
|
||||
artifact,
|
||||
ArtifactLink {
|
||||
id: artifact,
|
||||
alias: "view_review".into(),
|
||||
name: "View review".into(),
|
||||
description: String::new(),
|
||||
owner: first,
|
||||
relation: ArtifactRelation::References,
|
||||
uri: "./artifacts/review.md".into(),
|
||||
revision: None,
|
||||
fragment: None,
|
||||
},
|
||||
);
|
||||
let original_source = language::format(&project);
|
||||
let mut session = Session::new(project.clone());
|
||||
let copy_id = session
|
||||
.execute(command(&project, diagram).unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let copied = session.project().clone();
|
||||
assert_only_presentation_ids_changed(
|
||||
&project.diagrams[&diagram],
|
||||
&copied.diagrams[©_id],
|
||||
);
|
||||
let mut normalized = copied.clone();
|
||||
normalized.diagrams.remove(©_id);
|
||||
assert_eq!(normalized, project);
|
||||
assert_eq!(language::format(&normalized), original_source);
|
||||
assert_eq!(language::parse(&language::format(&copied)).unwrap(), copied);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &copied);
|
||||
assert_eq!(copied.artifact_links, project.artifact_links);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_state_view_preserves_machine_nesting_and_collapse_with_unique_aliases() {
|
||||
let mut project = language::parse(
|
||||
r#"arcadia 3; project operating_modes "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
oa_region nested "Nested" { parent @ready; }
|
||||
oa_state active "Active" { parent @nested; }
|
||||
diagram states "Operating modes" { projection { StateMachine { machine @modes; }; }; }
|
||||
node ready_view { diagram @states; element @ready; }
|
||||
node main_view { diagram @states; element @main; }
|
||||
node nested_view { diagram @states; element @nested; }
|
||||
node active_view { diagram @states; element @active; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let diagram = project.diagrams.values().next().unwrap().id;
|
||||
let ready = project
|
||||
.elements
|
||||
.values()
|
||||
.find(|element| element.alias == "ready")
|
||||
.unwrap()
|
||||
.id;
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|node| node.element == ready)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let source = project.diagrams[&diagram].clone();
|
||||
let mut session = Session::new(project);
|
||||
for _ in 0..3 {
|
||||
let id = session
|
||||
.execute(command(session.project(), diagram).unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_only_presentation_ids_changed(&source, &session.project().diagrams[&id]);
|
||||
let projected = projection::project_view(session.project(), id);
|
||||
let expected = projection::project_view(session.project(), diagram);
|
||||
assert_eq!(
|
||||
projected
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|node| node.element)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
expected.nodes.iter().map(|node| node.element).collect()
|
||||
);
|
||||
}
|
||||
let aliases: Vec<_> = session
|
||||
.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(_, alias)| alias)
|
||||
.collect();
|
||||
assert_eq!(aliases.len(), aliases.iter().collect::<BTreeSet<_>>().len());
|
||||
assert_eq!(
|
||||
language::parse(&language::format(session.project())).unwrap(),
|
||||
*session.project()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_view_does_not_propose_an_empty_replacement() {
|
||||
let project = Project::empty();
|
||||
assert!(command(&project, Id::new_v4()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -234,9 +234,15 @@ pub fn view(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
let footer = container(
|
||||
row![
|
||||
text(short(if app.busy { "Working…" } else { &app.status }, 145))
|
||||
.size(11)
|
||||
.width(Fill),
|
||||
container(tooltip(
|
||||
text(short(if app.busy { "Working…" } else { &app.status }, 145)).size(11),
|
||||
container(text(if app.busy { "Working…" } else { &app.status }).size(12))
|
||||
.padding(10)
|
||||
.max_width(600)
|
||||
.style(style::card),
|
||||
tooltip::Position::Top,
|
||||
))
|
||||
.width(Fill),
|
||||
label(format!(
|
||||
"{} objects · {} links",
|
||||
p.elements.len(),
|
||||
@@ -410,6 +416,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
diagram: id,
|
||||
selected: app.selected,
|
||||
zoom: app.zoom,
|
||||
@@ -635,6 +642,36 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
}
|
||||
// Keep a discoverable primary action; secondary actions live in Actions.
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor))
|
||||
{
|
||||
let draw = app.page == Page::Diagram
|
||||
&& app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(p, diagram))
|
||||
.any(|id| id == app.selected)
|
||||
});
|
||||
body = body.push(
|
||||
button(
|
||||
text(if draw {
|
||||
"Draw communication means"
|
||||
} else {
|
||||
"Configure communication means…"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(if draw {
|
||||
Message::Connection(crate::connection_edit::Event::Begin(
|
||||
crate::connection_tool::Tool::CommunicationMean,
|
||||
app.selected,
|
||||
))
|
||||
} else {
|
||||
Message::AddRelationKind(RelationKind::CommunicationMean, false)
|
||||
}),
|
||||
);
|
||||
}
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::System | Kind::SystemActor))
|
||||
|
||||
@@ -12,6 +12,7 @@ use iced::{
|
||||
};
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
mod communication;
|
||||
pub(crate) mod recovery;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -31,6 +32,7 @@ pub enum Event {
|
||||
Scope(String),
|
||||
MappingTarget(Id, Choice),
|
||||
MappingKind(Id, Kind),
|
||||
CommunicationPort(Id, bool, communication::PortMenuChoice),
|
||||
Resolve(Id, TransitionField, FieldDecision),
|
||||
Preview,
|
||||
}
|
||||
@@ -125,10 +127,12 @@ impl Relationship {
|
||||
return validation::can_create_interface_role(p, id);
|
||||
}
|
||||
if self.kind == RelationKind::ExchangeAllocation {
|
||||
return p
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::BoundaryExchange);
|
||||
return p.relations.get(&id).is_some_and(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::BoundaryExchange | RelationKind::CommunicationMean
|
||||
)
|
||||
});
|
||||
}
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
Kind::ALL
|
||||
@@ -187,6 +191,10 @@ impl Relationship {
|
||||
format!("{} {verb} {}", p.named(source), p.named(target))
|
||||
}
|
||||
fn default_orientation(&mut self, p: &Project) {
|
||||
if self.kind == RelationKind::CommunicationMean {
|
||||
self.oriented = false;
|
||||
return;
|
||||
}
|
||||
self.oriented = !self
|
||||
.source
|
||||
.as_ref()
|
||||
@@ -230,8 +238,15 @@ impl Relationship {
|
||||
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
|
||||
*condition = self.condition.text();
|
||||
}
|
||||
if let RelationBody::BoundaryExchange { oriented, .. } = &mut body {
|
||||
if let RelationBody::BoundaryExchange {
|
||||
oriented, items, ..
|
||||
}
|
||||
| RelationBody::CommunicationMean {
|
||||
oriented, items, ..
|
||||
} = &mut body
|
||||
{
|
||||
*oriented = self.oriented;
|
||||
*items = self.items.clone();
|
||||
}
|
||||
let commands = vec![Command::CreateRelation(Relation {
|
||||
id,
|
||||
@@ -248,6 +263,7 @@ impl Relationship {
|
||||
pub struct Transition {
|
||||
pub request: TransitionRequest,
|
||||
pub plan: TransitionPlan,
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
}
|
||||
impl Workflow {
|
||||
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
|
||||
@@ -334,7 +350,11 @@ impl Workflow {
|
||||
let mut request = TransitionRequest::new(direction, vec![selected]);
|
||||
request.scope = DependencyScope::Required;
|
||||
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
|
||||
Ok(Self::Transition(Box::new(Transition { request, plan })))
|
||||
Ok(Self::Transition(Box::new(Transition {
|
||||
request,
|
||||
plan,
|
||||
communication_ports: Default::default(),
|
||||
})))
|
||||
}
|
||||
/// Return a complete accepted candidate only on explicit Apply. Intermediate
|
||||
/// choices and previews never mutate the Session.
|
||||
@@ -439,6 +459,9 @@ impl Workflow {
|
||||
},
|
||||
Self::Transition(d) => {
|
||||
match event {
|
||||
Event::CommunicationPort(exchange, source, value) => {
|
||||
d.choose_communication_port(p, exchange, source, value)?;
|
||||
}
|
||||
Event::Scope(scope) => {
|
||||
d.request.scope = match scope.as_str() {
|
||||
"Required references" => DependencyScope::Required,
|
||||
@@ -546,6 +569,16 @@ impl Workflow {
|
||||
Event::Apply => {
|
||||
let mut candidate =
|
||||
apply_transition(p, &d.plan).map_err(|e| e.to_string())?;
|
||||
// Behavior is already usable in its dedicated workspace. An
|
||||
// unrequested architecture placement would alter an unrelated
|
||||
// view, including its routes, just to display this operation.
|
||||
if d.request.destination_diagram.is_none()
|
||||
&& d.request.sources.first().is_some_and(|source| {
|
||||
crate::behavior::selected_behavior(p, *source).is_some()
|
||||
})
|
||||
{
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
let layer = d.request.direction.target_layer();
|
||||
let diagram = d
|
||||
.request
|
||||
@@ -720,10 +753,13 @@ impl Workflow {
|
||||
.padding(9),
|
||||
);
|
||||
}
|
||||
if d.kind == RelationKind::BoundaryExchange {
|
||||
if matches!(
|
||||
d.kind,
|
||||
RelationKind::BoundaryExchange | RelationKind::CommunicationMean
|
||||
) {
|
||||
content = content.push(
|
||||
checkbox(d.oriented)
|
||||
.label("Oriented component exchange")
|
||||
.label("Directed source → target (unchecked: bidirectional)")
|
||||
.on_toggle(|v| Message::Workflow(Event::Oriented(v))),
|
||||
);
|
||||
}
|
||||
@@ -753,6 +789,14 @@ impl Workflow {
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
d.kind,
|
||||
RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::CommunicationMean
|
||||
) {
|
||||
content = content.push(text("Declared carried items").size(14));
|
||||
for item in p
|
||||
.elements
|
||||
.values()
|
||||
@@ -905,6 +949,9 @@ impl Workflow {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(ports) = d.communication_view(p, source) {
|
||||
item = item.push(ports);
|
||||
}
|
||||
content = content.push(
|
||||
iced::widget::container(item)
|
||||
.padding(12)
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Explicit SA endpoint decisions for an operational communication counterpart.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct PortDraft {
|
||||
source: Option<CommunicationPortChoice>,
|
||||
target: Option<CommunicationPortChoice>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PortOption {
|
||||
Reuse(Id),
|
||||
Create(ComponentPortKind, ComponentPortOrientation),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PortMenuChoice {
|
||||
option: PortOption,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PortMenuChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(p: &Project, value: &CommunicationPortChoice) -> PortMenuChoice {
|
||||
match value {
|
||||
CommunicationPortChoice::Reuse { port } => PortMenuChoice {
|
||||
option: PortOption::Reuse(*port),
|
||||
label: format!("Reuse {}", choice(p, *port).label),
|
||||
},
|
||||
CommunicationPortChoice::Create {
|
||||
kind, orientation, ..
|
||||
} => creation(*kind, *orientation),
|
||||
}
|
||||
}
|
||||
|
||||
fn creation(kind: ComponentPortKind, orientation: ComponentPortOrientation) -> PortMenuChoice {
|
||||
PortMenuChoice {
|
||||
option: PortOption::Create(kind, orientation),
|
||||
label: match kind {
|
||||
ComponentPortKind::Standard => "Create Standard port · bidirectional".into(),
|
||||
ComponentPortKind::Flow => format!("Create Flow port · {orientation:?}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl Transition {
|
||||
pub(super) fn sync_communication_ports(&mut self) {
|
||||
self.request.communication_endpoints = self
|
||||
.communication_ports
|
||||
.iter()
|
||||
.filter_map(|(id, draft)| {
|
||||
Some(CommunicationEndpointDecision {
|
||||
source: *id,
|
||||
source_port: draft.source.clone()?,
|
||||
target_port: draft.target.clone()?,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
pub(super) fn choose_communication_port(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
exchange: Id,
|
||||
source: bool,
|
||||
choice: PortMenuChoice,
|
||||
) -> Result<(), String> {
|
||||
if self.request.direction != TransitionDirection::OaToSa
|
||||
|| !p
|
||||
.relations
|
||||
.get(&exchange)
|
||||
.is_some_and(|r| r.kind() == RelationKind::CommunicationMean)
|
||||
{
|
||||
return Err("Choose an operational communication means in this transition.".into());
|
||||
}
|
||||
let value = match choice.option {
|
||||
PortOption::Reuse(port) => CommunicationPortChoice::Reuse { port },
|
||||
PortOption::Create(kind, orientation) => {
|
||||
let id = Id::new_v4();
|
||||
let side = if source { "source" } else { "target" };
|
||||
let name = format!("{} · {side} port", p.named(exchange));
|
||||
CommunicationPortChoice::Create {
|
||||
id,
|
||||
alias: p.fresh_alias(&name, "port", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
kind,
|
||||
orientation,
|
||||
}
|
||||
}
|
||||
};
|
||||
let draft = self.communication_ports.entry(exchange).or_default();
|
||||
if source {
|
||||
draft.source = Some(value);
|
||||
} else {
|
||||
draft.target = Some(value);
|
||||
}
|
||||
self.sync_communication_ports();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn communication_view<'a>(
|
||||
&'a self,
|
||||
p: &'a Project,
|
||||
exchange: Id,
|
||||
) -> Option<Element<'a, Message>> {
|
||||
if self.request.direction != TransitionDirection::OaToSa {
|
||||
return None;
|
||||
}
|
||||
let relation = p.relations.get(&exchange)?;
|
||||
if relation.kind() != RelationKind::CommunicationMean {
|
||||
return None;
|
||||
}
|
||||
let mut content = column![
|
||||
text("System communication endpoints").size(14),
|
||||
text("Choose both component ports explicitly. Participant correspondence still maps entities to participants. New ports are created together with the reviewed exchange; interface and functional-port allocation decisions remain explicit.").size(12),
|
||||
].spacing(8).width(Fill);
|
||||
for source in [true, false] {
|
||||
let participant = if source {
|
||||
relation.source()
|
||||
} else {
|
||||
relation.target()
|
||||
};
|
||||
let owner = self
|
||||
.request
|
||||
.mappings
|
||||
.iter()
|
||||
.find(|m| m.source == participant)
|
||||
.and_then(|m| m.target)
|
||||
.or_else(|| {
|
||||
self.plan
|
||||
.changes
|
||||
.iter()
|
||||
.find(|c| c.source == participant)
|
||||
.map(|c| c.target)
|
||||
});
|
||||
let mut options = vec![creation(
|
||||
ComponentPortKind::Standard,
|
||||
ComponentPortOrientation::Unset,
|
||||
)];
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::InOut,
|
||||
] {
|
||||
options.push(creation(ComponentPortKind::Flow, orientation));
|
||||
}
|
||||
options.extend(
|
||||
p.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == Layer::Sa
|
||||
&& owner == e.parent
|
||||
&& owner.is_some()
|
||||
&& matches!(port_contract(e), Some(PortContract::Component { .. }))
|
||||
})
|
||||
.map(|e| selected(p, &CommunicationPortChoice::Reuse { port: e.id })),
|
||||
);
|
||||
let draft = self.communication_ports.get(&exchange);
|
||||
let value = draft
|
||||
.and_then(|d| {
|
||||
if source {
|
||||
d.source.as_ref()
|
||||
} else {
|
||||
d.target.as_ref()
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
self.request
|
||||
.communication_endpoints
|
||||
.iter()
|
||||
.find(|d| d.source == exchange)
|
||||
.map(|d| {
|
||||
if source {
|
||||
&d.source_port
|
||||
} else {
|
||||
&d.target_port
|
||||
}
|
||||
})
|
||||
});
|
||||
let value = value.map(|v| selected(p, v));
|
||||
if let Some(value) = &value
|
||||
&& !options.contains(value)
|
||||
{
|
||||
options.push(value.clone());
|
||||
}
|
||||
content = content
|
||||
.push(
|
||||
text(format!(
|
||||
"{} · {}",
|
||||
if source {
|
||||
"Source participant"
|
||||
} else {
|
||||
"Target participant"
|
||||
},
|
||||
p.named(participant)
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
.push(
|
||||
pick_list(options, value, move |choice| {
|
||||
Message::Workflow(Event::CommunicationPort(exchange, source, choice))
|
||||
})
|
||||
.placeholder("Choose a component port")
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
Some(content.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn from_request(request: &TransitionRequest) -> BTreeMap<Id, PortDraft> {
|
||||
request
|
||||
.communication_endpoints
|
||||
.iter()
|
||||
.map(|d| {
|
||||
(
|
||||
d.source,
|
||||
PortDraft {
|
||||
source: Some(d.source_port.clone()),
|
||||
target: Some(d.target_port.clone()),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn communication_requires_two_explicit_ports_and_recovers_the_partial_choice() {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let oa_b = p
|
||||
.add_element(Kind::Actor, Layer::Oa, "Partner", None)
|
||||
.unwrap();
|
||||
let sa_a = p
|
||||
.add_element(Kind::System, Layer::Sa, "Controller", None)
|
||||
.unwrap();
|
||||
let sa_b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::CommunicationMean, "Radio", oa_a, oa_b);
|
||||
let mut workflow = Workflow::transition(&p, exchange).unwrap();
|
||||
let Workflow::Transition(t) = &mut workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
for (source, target) in [(oa_a, sa_a), (oa_b, sa_b)] {
|
||||
t.request.mappings.push(TransitionMapping {
|
||||
source,
|
||||
target: Some(target),
|
||||
target_kind: None,
|
||||
});
|
||||
}
|
||||
workflow.update(&p, Event::Preview).unwrap();
|
||||
let make_port = || creation(ComponentPortKind::Standard, ComponentPortOrientation::Unset);
|
||||
workflow
|
||||
.update(&p, Event::CommunicationPort(exchange, true, make_port()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(t) = &workflow else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(t.request.communication_endpoints.is_empty());
|
||||
assert!(!t.plan.conflicts.is_empty());
|
||||
let raw = workflow.recovery_snapshot().unwrap();
|
||||
let mut restored = Workflow::from_recovery(&p, raw.clone()).unwrap();
|
||||
assert_eq!(restored.recovery_snapshot().unwrap(), raw);
|
||||
restored
|
||||
.update(&p, Event::CommunicationPort(exchange, false, make_port()))
|
||||
.unwrap();
|
||||
let Workflow::Transition(t) = &restored else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(t.plan.conflicts.is_empty(), "{:?}", t.plan.conflicts);
|
||||
let decision = t.request.communication_endpoints[0].clone();
|
||||
let CommunicationPortChoice::Create { id: from, .. } = decision.source_port else {
|
||||
unreachable!()
|
||||
};
|
||||
let CommunicationPortChoice::Create { id: to, .. } = decision.target_port else {
|
||||
unreachable!()
|
||||
};
|
||||
let before = p.clone();
|
||||
let accepted = restored.update(&p, Event::Apply).unwrap().unwrap();
|
||||
assert_eq!(accepted.elements[&from].parent, Some(sa_a));
|
||||
assert_eq!(accepted.elements[&to].parent, Some(sa_b));
|
||||
assert!(accepted.relations.values().any(|r| matches!(r.body,
|
||||
RelationBody::BoundaryExchange { source, target, oriented: false, .. }
|
||||
if source == from && target == to)));
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ enum Saved {
|
||||
Transition {
|
||||
base: String,
|
||||
request: TransitionRequest,
|
||||
#[serde(default)]
|
||||
communication_ports: std::collections::BTreeMap<Id, communication::PortDraft>,
|
||||
},
|
||||
}
|
||||
pub(crate) fn endpoint(p: &Project, port: PortChoice) -> EndpointChoice {
|
||||
@@ -52,6 +54,40 @@ fn check(p: &Project, base: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
impl Workflow {
|
||||
pub(crate) fn from_recovery_after_source5_upgrade(
|
||||
p: &Project,
|
||||
legacy_base: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Self, String> {
|
||||
let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?;
|
||||
match &mut saved {
|
||||
Saved::Semantic(value) => {
|
||||
return crate::semantic_review::State::from_recovery_after_source5_upgrade(
|
||||
p,
|
||||
legacy_base,
|
||||
value.take(),
|
||||
)
|
||||
.map(|state| Self::Semantic(Box::new(state)));
|
||||
}
|
||||
Saved::Ports(value) => {
|
||||
return crate::port_workflow::State::from_recovery_after_source5_upgrade(
|
||||
p,
|
||||
legacy_base,
|
||||
value.take(),
|
||||
)
|
||||
.map(|state| Self::Ports(Box::new(state)));
|
||||
}
|
||||
Saved::Relation { base, .. }
|
||||
| Saved::ManualTrace { base, .. }
|
||||
| Saved::Transition { base, .. } => {
|
||||
if base != legacy_base {
|
||||
return Err("Workflow recovery does not match the accepted source-5 model identity and revision.".into());
|
||||
}
|
||||
*base = project_fingerprint(p);
|
||||
}
|
||||
}
|
||||
Self::from_recovery(p, serde_json::to_value(saved).map_err(|e| e.to_string())?)
|
||||
}
|
||||
pub fn recovery_snapshot(&self) -> Result<serde_json::Value, String> {
|
||||
let saved = match self {
|
||||
Self::Semantic(state) => Saved::Semantic(state.recovery_snapshot()?),
|
||||
@@ -84,6 +120,7 @@ impl Workflow {
|
||||
Self::Transition(d) => Saved::Transition {
|
||||
base: d.plan.base_fingerprint.clone(),
|
||||
request: d.request.clone(),
|
||||
communication_ports: d.communication_ports.clone(),
|
||||
},
|
||||
};
|
||||
serde_json::to_value(saved).map_err(|e| e.to_string())
|
||||
@@ -148,10 +185,28 @@ impl Workflow {
|
||||
}
|
||||
Ok(workflow)
|
||||
}
|
||||
Saved::Transition { base, request } => {
|
||||
Saved::Transition {
|
||||
base,
|
||||
request,
|
||||
mut communication_ports,
|
||||
} => {
|
||||
check(p, &base)?;
|
||||
if communication_ports.len() > crate::source_workspace::LIMITS.model.max_records {
|
||||
return Err("Recovered transition exceeds endpoint draft limits.".into());
|
||||
}
|
||||
for (id, draft) in communication::from_request(&request) {
|
||||
communication_ports.entry(id).or_insert(draft);
|
||||
}
|
||||
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
|
||||
Ok(Self::Transition(Box::new(Transition { request, plan })))
|
||||
let mut transition = Transition {
|
||||
request,
|
||||
plan,
|
||||
communication_ports,
|
||||
};
|
||||
transition.sync_communication_ports();
|
||||
transition.plan =
|
||||
plan_transition(p, &transition.request).map_err(|e| e.to_string())?;
|
||||
Ok(Self::Transition(Box::new(transition)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user