Integrate process, scenario and typed history authoring checkpoint
This commit is contained in:
@@ -1100,11 +1100,13 @@ impl App {
|
||||
// still validates document, scenario and revision before accepting it.
|
||||
if matches!(
|
||||
&message,
|
||||
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
|
||||
| Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| crate::behavior::topology::CanvasEvent::Viewport(..)
|
||||
)
|
||||
Message::ScenarioCanvas(
|
||||
crate::behavior::scenario::CanvasEvent::Scrolled(..)
|
||||
| crate::behavior::scenario::CanvasEvent::Restored(..)
|
||||
) | Message::TopologyCanvas(
|
||||
crate::behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| crate::behavior::topology::CanvasEvent::Viewport(..)
|
||||
)
|
||||
) {
|
||||
return self.handle(message);
|
||||
}
|
||||
@@ -1362,6 +1364,18 @@ impl App {
|
||||
Message::ScenarioCanvas(event) => {
|
||||
use crate::behavior::scenario::{self, CanvasEvent};
|
||||
let context = event.context();
|
||||
if let CanvasEvent::Restored(_, token, area, offset) = event {
|
||||
if self.scenario_context().is_some_and(|current| {
|
||||
current.document == context.document && current.scenario == context.scenario
|
||||
}) && self
|
||||
.behavior
|
||||
.scenario
|
||||
.restored(context, token, area, offset)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let CanvasEvent::Scrolled(_, area, offset) = event {
|
||||
// Inner and outer scroll notifications may come from the
|
||||
// same frame. Accept both across the first epoch bump,
|
||||
@@ -1380,6 +1394,45 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
CanvasEvent::OpenUse(_, occurrence) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
{
|
||||
self.status = "Apply or cancel pending edits before opening a referenced scenario.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let target = state.open_use(self.project(), context, occurrence);
|
||||
self.behavior.scenario = state;
|
||||
match target {
|
||||
Ok(target) => {
|
||||
self.select(target);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Back(_) => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
|
||||
{
|
||||
self.status =
|
||||
"Apply or cancel pending edits before returning to the caller."
|
||||
.into();
|
||||
return Task::none();
|
||||
}
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let caller = state.back(self.project(), context);
|
||||
self.behavior.scenario = state;
|
||||
if let Some(caller) = caller {
|
||||
self.select(caller.occurrence);
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if let Some(context) = self.scenario_context() {
|
||||
self.behavior.scenario.restore_caller(context, caller);
|
||||
}
|
||||
} else {
|
||||
self.status =
|
||||
"The calling scenario reference is no longer available.".into();
|
||||
}
|
||||
}
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id)
|
||||
@@ -1405,6 +1458,29 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match mutation {
|
||||
CanvasEvent::BeginOccurrence {
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
..
|
||||
} => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_occurrence(
|
||||
self.project(),
|
||||
context,
|
||||
tool,
|
||||
slot,
|
||||
lifeline,
|
||||
);
|
||||
self.behavior.scenario = state;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.status = "Complete the selected behavior at the highlighted sequence position.".into();
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
}
|
||||
}
|
||||
CanvasEvent::Begin {
|
||||
slot,
|
||||
sender,
|
||||
|
||||
@@ -279,6 +279,166 @@ fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
|
||||
assert!(app.architecture.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
use iced::{keyboard, mouse, widget::canvas::Program};
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Start(Kind::Entity, Some(Position { x: 200., y: 120. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Response team".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let before = app.document.bundle().clone();
|
||||
let element = app.selected;
|
||||
let node = app.selected_node;
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: element,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
|
||||
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = iced::widget::canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
);
|
||||
let message = canvas
|
||||
.update(&mut state, &key, bounds, cursor)
|
||||
.expect("focused F2 opens the selected semantic name")
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
send(&mut app, Event::Name(" Emergency team 雨 ".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!((app.selected, app.selected_node), (element, node));
|
||||
assert_eq!(
|
||||
app.project().elements[&element].name,
|
||||
" Emergency team 雨 "
|
||||
);
|
||||
assert_eq!(app.project().diagrams, diagrams);
|
||||
assert_eq!((app.zoom, app.pan), camera);
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
let context = app.route_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Rename(context, element, Position { x: 200., y: 120. }),
|
||||
);
|
||||
send(&mut app, Event::Name("Cancelled name".into()));
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_contextual_allocation_creates_work_relation_and_node_in_one_undo() {
|
||||
for (layer, performer_kind, work_kind) in [
|
||||
(Layer::Oa, Kind::Entity, Kind::Activity),
|
||||
(Layer::Sa, Kind::System, Kind::Function),
|
||||
] {
|
||||
let mut app = setup(layer);
|
||||
send(
|
||||
&mut app,
|
||||
Event::Start(performer_kind, Some(Position { x: 20., y: 20. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Control team".into()));
|
||||
send(&mut app, Event::Submit);
|
||||
let performer = app.selected;
|
||||
let before = app.document.bundle().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Allocate(work_kind, performer, Some(Position { x: 55., y: 90. })),
|
||||
);
|
||||
send(&mut app, Event::Name("Assess pressure".into()));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
send(&mut app, Event::Submit);
|
||||
assert!(app.architecture.is_none(), "{}", app.status);
|
||||
let work = app.selected;
|
||||
assert_eq!(app.project().elements[&work].parent, None);
|
||||
assert!(
|
||||
app.project()
|
||||
.relations
|
||||
.values()
|
||||
.any(|r| r.kind() == RelationKind::Allocation
|
||||
&& r.source() == performer
|
||||
&& r.target() == work)
|
||||
);
|
||||
let node = &app.project().diagrams[&app.active_diagram.unwrap()].nodes
|
||||
[&app.selected_node.unwrap()];
|
||||
assert_eq!(node.position, Position { x: 55., y: 90. });
|
||||
let accepted = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn architecture_rejected_submission_keeps_inline_name_visible_and_invalid_click_is_ignored() {
|
||||
let mut app = setup(Layer::Oa);
|
||||
send(&mut app, Event::Start(Kind::Entity, None));
|
||||
let context = app.route_context().unwrap();
|
||||
send(
|
||||
&mut app,
|
||||
Event::Click(
|
||||
context,
|
||||
Position {
|
||||
x: f32::NAN,
|
||||
y: 30.,
|
||||
},
|
||||
),
|
||||
);
|
||||
assert!(app.architecture.as_ref().unwrap().unnamed_tool());
|
||||
send(&mut app, Event::Click(context, Position { x: 40., y: 30. }));
|
||||
send(&mut app, Event::Name("Retained team".into()));
|
||||
// A replaced document must not accept a queued draft submission.
|
||||
app.canvas_document = Id::new_v4();
|
||||
let before = app.document.bundle().clone();
|
||||
send(&mut app, Event::Submit);
|
||||
let draft = app.architecture.as_ref().unwrap();
|
||||
assert!(draft.inline());
|
||||
assert!(draft.error.is_some());
|
||||
assert!(
|
||||
matches!(&draft.saved.intent, architecture_authoring::Intent::Create { name, .. } if name == "Retained team")
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
|
||||
use crate::workflow::{Event as W, Workflow};
|
||||
|
||||
@@ -32,6 +32,7 @@ mod scenario_scroll {
|
||||
pub(super) fn observe<'a>(
|
||||
context: scenario::Context,
|
||||
area: scenario::ScrollArea,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
Element::new(Observer {
|
||||
@@ -39,6 +40,7 @@ mod scenario_scroll {
|
||||
context,
|
||||
area,
|
||||
process: false,
|
||||
restore,
|
||||
})
|
||||
}
|
||||
pub(super) fn observe_topology<'a>(
|
||||
@@ -56,6 +58,7 @@ mod scenario_scroll {
|
||||
},
|
||||
area,
|
||||
process: true,
|
||||
restore: None,
|
||||
})
|
||||
}
|
||||
struct Observer<'a, R = Renderer> {
|
||||
@@ -63,11 +66,13 @@ mod scenario_scroll {
|
||||
context: scenario::Context,
|
||||
area: scenario::ScrollArea,
|
||||
process: bool,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
}
|
||||
type Report = Option<(bool, Id, Id, Vector)>;
|
||||
struct ObservedScroll {
|
||||
identity: (Id, Id),
|
||||
report: Report,
|
||||
restored: Option<Id>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Translation(Option<Vector>);
|
||||
@@ -84,6 +89,28 @@ mod scenario_scroll {
|
||||
self.0 = Some(translation);
|
||||
}
|
||||
}
|
||||
struct RestoreTranslation {
|
||||
offset: Vector,
|
||||
applied: bool,
|
||||
}
|
||||
impl widget::Operation for RestoreTranslation {
|
||||
// Restore only this wrapper's root Scrollable, never its nested canvas.
|
||||
fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {}
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
_: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
_: Rectangle,
|
||||
_: Vector,
|
||||
state: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
state.scroll_to(widget::operation::scrollable::AbsoluteOffset {
|
||||
x: Some(self.offset.x),
|
||||
y: Some(self.offset.y),
|
||||
});
|
||||
self.applied = true;
|
||||
}
|
||||
}
|
||||
impl<R: renderer::Renderer> Observer<'_, R> {
|
||||
fn report(
|
||||
&mut self,
|
||||
@@ -92,6 +119,25 @@ mod scenario_scroll {
|
||||
renderer: &R,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
) {
|
||||
let restore = self.restore.filter(|request| {
|
||||
!self.process
|
||||
&& (request.document, request.owner)
|
||||
== (self.context.document, self.context.scenario)
|
||||
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
|
||||
});
|
||||
let receipt = restore.and_then(|request| {
|
||||
let mut operation = RestoreTranslation {
|
||||
offset: request.offset,
|
||||
applied: false,
|
||||
};
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&mut operation,
|
||||
);
|
||||
operation.applied.then_some(request.token)
|
||||
});
|
||||
let mut offset = Translation::default();
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
@@ -106,7 +152,19 @@ mod scenario_scroll {
|
||||
self.context.scenario,
|
||||
offset,
|
||||
));
|
||||
let previous = &mut tree.state.downcast_mut::<ObservedScroll>().report;
|
||||
let state = tree.state.downcast_mut::<ObservedScroll>();
|
||||
if let Some(token) = receipt {
|
||||
state.restored = Some(token);
|
||||
state.report = next;
|
||||
shell.publish(Message::ScenarioCanvas(scenario::CanvasEvent::Restored(
|
||||
self.context,
|
||||
token,
|
||||
self.area,
|
||||
offset,
|
||||
)));
|
||||
return;
|
||||
}
|
||||
let previous = &mut state.report;
|
||||
if *previous != next {
|
||||
*previous = next;
|
||||
shell.publish(if self.process {
|
||||
@@ -139,6 +197,7 @@ mod scenario_scroll {
|
||||
widget::tree::State::new(ObservedScroll {
|
||||
identity: (self.context.document, self.context.scenario),
|
||||
report: None,
|
||||
restored: None,
|
||||
})
|
||||
}
|
||||
fn children(&self) -> Vec<widget::Tree> {
|
||||
@@ -274,6 +333,7 @@ mod scenario_scroll {
|
||||
context,
|
||||
area: scenario::ScrollArea::Canvas,
|
||||
process: false,
|
||||
restore: None,
|
||||
content: scrollable(iced::widget::Space::new().width(200.).height(height))
|
||||
.id("observer-test")
|
||||
.width(200.)
|
||||
@@ -320,6 +380,154 @@ mod scenario_scroll {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn actual_offset(view: &mut Observer<'_, ()>, tree: &mut widget::Tree) -> Vector {
|
||||
let node = view.layout(
|
||||
tree,
|
||||
&(),
|
||||
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
|
||||
);
|
||||
let mut operation = Translation::default();
|
||||
view.operate(tree, Layout::new(&node), &(), &mut operation);
|
||||
operation.0.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_caller_restores_mounted_scroll_once_with_clamping_and_app_receipt() {
|
||||
use scenario::{
|
||||
CanvasEvent, ScrollArea,
|
||||
direct_tests::{navigation_event, navigation_fixture},
|
||||
};
|
||||
let redraw = || {
|
||||
iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
))
|
||||
};
|
||||
for area in [ScrollArea::Canvas, ScrollArea::Page] {
|
||||
for returned_height in [600., 250.] {
|
||||
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
|
||||
let mut app = App::with_project(project.clone());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
let source = app.document.bundle().clone();
|
||||
let mut view = observer(600., app.scenario_context().unwrap());
|
||||
view.area = area;
|
||||
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
||||
for message in update(&mut view, &mut tree, redraw()).0 {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
for message in update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
|
||||
}),
|
||||
)
|
||||
.0
|
||||
{
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
|
||||
view = observer(600., app.scenario_context().unwrap());
|
||||
view.area = area;
|
||||
view.diff(&mut tree);
|
||||
for message in update(&mut view, &mut tree, redraw()).0 {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
|
||||
navigation_event(&mut app, CanvasEvent::Back);
|
||||
assert_eq!(app.selected, use_id);
|
||||
let context = app.scenario_context().unwrap();
|
||||
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
|
||||
view = observer(returned_height, context);
|
||||
view.area = area;
|
||||
view.restore = Some(request);
|
||||
view.diff(&mut tree);
|
||||
let expected = Vector::new(0., (returned_height - 200.).min(180.));
|
||||
let messages = update(&mut view, &mut tree, redraw()).0;
|
||||
assert!(
|
||||
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
|
||||
);
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), expected);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert!(
|
||||
app.behavior
|
||||
.scenario
|
||||
.scroll_restore(app.scenario_context().unwrap(), area)
|
||||
.is_none()
|
||||
);
|
||||
// A delayed receipt leaves the same request in the view; later input must not snap back.
|
||||
for message in update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
|
||||
}),
|
||||
)
|
||||
.0
|
||||
{
|
||||
let _ = app.update(message);
|
||||
}
|
||||
let moved = expected - Vector::new(0., 20.);
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), moved);
|
||||
view.context = app.scenario_context().unwrap();
|
||||
view.diff(&mut tree);
|
||||
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), moved);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
|
||||
});
|
||||
let late_context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::Page(crate::app::Page::Diagram));
|
||||
let epoch = app.canvas_epoch;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
|
||||
late_context,
|
||||
request.token,
|
||||
area,
|
||||
Vector::ZERO,
|
||||
)));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
|
||||
for mismatch in 0..3 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut request = scenario::ScrollRestore {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
token: Id::new_v4(),
|
||||
offset: Vector::new(0., 180.),
|
||||
};
|
||||
match mismatch {
|
||||
0 => request.document = Id::new_v4(),
|
||||
1 => request.owner = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
let mut view = observer(600., context);
|
||||
view.process = mismatch == 2;
|
||||
view.restore = Some(request);
|
||||
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
|
||||
let messages = update(
|
||||
&mut view,
|
||||
&mut tree,
|
||||
iced::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
)
|
||||
.0;
|
||||
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::ScenarioCanvas(scenario::CanvasEvent::Restored(..))
|
||||
)));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn independent_process_owner_change_resets_actual_scroll_offsets() {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
@@ -711,8 +919,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
return out.push(label("Start with a Process, Scenario or State machine using Create element. Add occurrences or regions inside it to describe behavior.")).padding(24).into();
|
||||
};
|
||||
let e = &p.elements[&selected.id];
|
||||
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain)
|
||||
&& (uses_explicit_topology(p, e.id) || e.steps().is_none_or(Vec::is_empty));
|
||||
let compact_process = matches!(e.kind(), Kind::Process | Kind::FunctionalChain);
|
||||
if e.kind() == Kind::StateMachine {
|
||||
out = column![text("States & modes").size(20)].spacing(8);
|
||||
} else if compact_process {
|
||||
@@ -841,6 +1048,7 @@ struct Scene {
|
||||
scale: f32,
|
||||
scroll_offset: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
slots: Vec<SequenceSlot>,
|
||||
rows: BTreeMap<Id, (Id, f32)>,
|
||||
@@ -863,6 +1071,11 @@ struct SequenceInput {
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum SequencePress {
|
||||
Occurrence {
|
||||
tool: scenario::Tool,
|
||||
slot: scenario::Slot,
|
||||
lifeline: Option<Id>,
|
||||
},
|
||||
Message {
|
||||
source: Id,
|
||||
slot: scenario::Slot,
|
||||
@@ -893,6 +1106,7 @@ impl Scene {
|
||||
size: Size::new(700., 200.),
|
||||
selected,
|
||||
interaction: None,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
slots: vec![],
|
||||
rows: BTreeMap::new(),
|
||||
@@ -1518,6 +1732,7 @@ impl canvas::Program<Message> for Scene {
|
||||
| SequencePress::Reorder { start, moved, .. } => {
|
||||
*moved |= position.distance(*start) >= 3.
|
||||
}
|
||||
SequencePress::Occurrence { .. } => {}
|
||||
}
|
||||
}
|
||||
mouse::Cursor::Available(*position + self.scroll_offset)
|
||||
@@ -1576,6 +1791,17 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let CanvasTool::Add(tool) = tool {
|
||||
if !tool.canvas_occurrence() {
|
||||
return None;
|
||||
}
|
||||
state.press = Some(SequencePress::Occurrence {
|
||||
tool,
|
||||
slot: self.slot_at(point, None)?.slot,
|
||||
lifeline: tool.uses_lifeline().then(|| self.lane_at(point)).flatten(),
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if self.reorder_handle().is_some_and(|r| r.contains(point)) {
|
||||
state.press = Some(SequencePress::Reorder {
|
||||
occurrence: self.selected,
|
||||
@@ -1597,6 +1823,23 @@ impl canvas::Program<Message> for Scene {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
SequencePress::Occurrence {
|
||||
tool: captured,
|
||||
slot,
|
||||
lifeline,
|
||||
} if tool == CanvasTool::Add(captured) && world.is_some() => {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(
|
||||
CanvasEvent::BeginOccurrence {
|
||||
context,
|
||||
tool: captured,
|
||||
slot,
|
||||
lifeline,
|
||||
},
|
||||
))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
SequencePress::Message { source, slot, .. }
|
||||
if tool == CanvasTool::Message(scenario::MessageStyle::Timer)
|
||||
&& world.is_some() =>
|
||||
@@ -1783,8 +2026,8 @@ impl canvas::Program<Message> for Scene {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
if let Some((context, tool, true)) = self.interaction {
|
||||
let active = state.context == Some(context) && state.tool == tool;
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable && state.context == Some(context) && state.tool == tool;
|
||||
let point = cursor
|
||||
.position_in(bounds)
|
||||
.map(|p| Point::new(p.x / self.scale, p.y / self.scale));
|
||||
@@ -1796,7 +2039,8 @@ impl canvas::Program<Message> for Scene {
|
||||
None
|
||||
}
|
||||
});
|
||||
if tool == scenario::CanvasTool::Select
|
||||
if editable
|
||||
&& tool == scenario::CanvasTool::Select
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
||||
@@ -1808,9 +2052,25 @@ impl canvas::Program<Message> for Scene {
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let slot = if let Some((_, slot)) = source {
|
||||
let placement = self.pending_placement.or_else(|| {
|
||||
active.then_some(state.press).flatten().and_then(|press| {
|
||||
if let SequencePress::Occurrence { slot, lifeline, .. } = press {
|
||||
Some((slot, lifeline))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
let slot = if let Some((slot, _)) = placement {
|
||||
self.slots.iter().find(|s| s.slot == slot)
|
||||
} else if matches!(tool, scenario::CanvasTool::Message(_)) || reorder.is_some() {
|
||||
} else if let Some((_, slot)) = source {
|
||||
self.slots.iter().find(|s| s.slot == slot)
|
||||
} else if editable
|
||||
&& (matches!(
|
||||
tool,
|
||||
scenario::CanvasTool::Message(_) | scenario::CanvasTool::Add(_)
|
||||
) || reorder.is_some())
|
||||
{
|
||||
point.and_then(|p| self.slot_at(p, reorder))
|
||||
} else {
|
||||
None
|
||||
@@ -1829,12 +2089,29 @@ impl canvas::Program<Message> for Scene {
|
||||
Color::WHITE,
|
||||
);
|
||||
f.fill_text(Text {
|
||||
content: short(&format!("Insert: {}", slot.label), 88),
|
||||
content: short(
|
||||
&format!(
|
||||
"{}: {}",
|
||||
if self.pending_placement.is_some() {
|
||||
"Pending"
|
||||
} else {
|
||||
"Insert"
|
||||
},
|
||||
slot.label
|
||||
),
|
||||
88,
|
||||
),
|
||||
position: Point::new(38., slot.y - 19.),
|
||||
size: 11.into(),
|
||||
color: style::TEAL,
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(x) = placement
|
||||
.and_then(|(_, lifeline)| lifeline)
|
||||
.and_then(|id| self.lanes.get(&id))
|
||||
{
|
||||
f.fill(&Path::circle(Point::new(*x, slot.y), 5.), style::TEAL);
|
||||
}
|
||||
if let Some((source, _)) = source
|
||||
&& let Some(x) = self.lanes.get(&source)
|
||||
&& let Some(point) = point
|
||||
@@ -2246,6 +2523,110 @@ mod direct_canvas_tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_clicked_occurrence_capture_cancels_on_view_and_context_changes() {
|
||||
for tool in [
|
||||
scenario::Tool::Action,
|
||||
scenario::Tool::Observation,
|
||||
scenario::Tool::Reuse,
|
||||
scenario::Tool::Fragment,
|
||||
] {
|
||||
for cancellation in 0..9 {
|
||||
let (f, mut scene) = scene();
|
||||
scene.interaction.as_mut().unwrap().1 = CanvasTool::Add(tool);
|
||||
let mut state = SequenceInput::default();
|
||||
let mut bounds = Rectangle::new(Point::ORIGIN, scene.size);
|
||||
let point = Point::new(scene.lanes[&f.sender], scene.slots[0].y);
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
assert!(
|
||||
send(
|
||||
&scene,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
bounds
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(matches!(
|
||||
state.press,
|
||||
Some(SequencePress::Occurrence { .. })
|
||||
));
|
||||
let cancellation_event = match cancellation {
|
||||
0 => {
|
||||
scene.interaction.as_mut().unwrap().0.epoch += 1;
|
||||
None
|
||||
}
|
||||
1 => {
|
||||
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
|
||||
None
|
||||
}
|
||||
2 => {
|
||||
scene.interaction.as_mut().unwrap().2 = false;
|
||||
None
|
||||
}
|
||||
3 => Some(canvas::Event::Mouse(mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: -1. },
|
||||
})),
|
||||
4 => Some(canvas::Event::Window(iced::window::Event::Resized(
|
||||
Size::new(1100., 700.),
|
||||
))),
|
||||
5 => {
|
||||
bounds.y -= 30.;
|
||||
None
|
||||
}
|
||||
6 => Some(canvas::Event::Window(iced::window::Event::Unfocused)),
|
||||
7 => Some(canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
modified_key: iced::keyboard::Key::Named(
|
||||
iced::keyboard::key::Named::Escape,
|
||||
),
|
||||
physical_key: iced::keyboard::key::Physical::Code(
|
||||
iced::keyboard::key::Code::Escape,
|
||||
),
|
||||
location: iced::keyboard::Location::Standard,
|
||||
modifiers: iced::keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = cancellation_event {
|
||||
let action = canvas::Program::update(
|
||||
&scene,
|
||||
&mut state,
|
||||
&event,
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
);
|
||||
assert!(action.and_then(|action| action.into_inner().0).is_none());
|
||||
}
|
||||
let cursor = if cancellation == 8 {
|
||||
mouse::Cursor::Unavailable
|
||||
} else {
|
||||
mouse::Cursor::Available(point)
|
||||
};
|
||||
let released = canvas::Program::update(
|
||||
&scene,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
);
|
||||
assert!(
|
||||
released.and_then(|action| action.into_inner().0).is_none(),
|
||||
"{tool:?} cancellation {cancellation}"
|
||||
);
|
||||
assert!(state.press.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn direct_selected_handle_reorders_and_hits_keep_semantic_identity() {
|
||||
let (f, mut scene) = scene();
|
||||
scene.interaction.as_mut().unwrap().1 = CanvasTool::Select;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -836,6 +836,12 @@ struct GraphEdge {
|
||||
incidence: bool,
|
||||
layer: EdgeLayer,
|
||||
}
|
||||
// Ordered exchange adjacency is a projection, never a persisted relation ID.
|
||||
struct OrderedPath {
|
||||
source: Id,
|
||||
target: Id,
|
||||
points: Vec<Point>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum EdgeLayer {
|
||||
Direct,
|
||||
@@ -863,6 +869,8 @@ struct Graph<'a> {
|
||||
scroll_offset: iced::Vector,
|
||||
nodes: Vec<GraphNode>,
|
||||
edges: Vec<GraphEdge>,
|
||||
ordered_paths: Vec<OrderedPath>,
|
||||
ordered: bool,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
@@ -884,11 +892,17 @@ impl<'a> Graph<'a> {
|
||||
let context_index = oa_core::topology_context::TopologyIndex::new(p);
|
||||
let choices = contributions(p, owner);
|
||||
let links = sequence_links(p, owner);
|
||||
let order = legacy_order(p, owner);
|
||||
let adjacency: Vec<_> = links
|
||||
.iter()
|
||||
.map(|r| (r.source(), r.target()))
|
||||
.chain(order.windows(2).map(|pair| (pair[0], pair[1])))
|
||||
.collect();
|
||||
let ids: BTreeSet<_> = choices.iter().map(|c| c.id).collect();
|
||||
let mut incoming: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
let mut ranks: BTreeMap<_, usize> = ids.iter().map(|id| (*id, 0)).collect();
|
||||
for r in &links {
|
||||
*incoming.entry(r.target()).or_default() += 1;
|
||||
for &(_, target) in &adjacency {
|
||||
*incoming.entry(target).or_default() += 1;
|
||||
}
|
||||
let mut queue: Vec<_> = choices
|
||||
.iter()
|
||||
@@ -903,8 +917,7 @@ impl<'a> Graph<'a> {
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
for r in links.iter().filter(|r| r.source() == id) {
|
||||
let next = r.target();
|
||||
for &(_, next) in adjacency.iter().filter(|(source, _)| *source == id) {
|
||||
ranks.insert(next, ranks[&next].max(ranks[&id] + 1));
|
||||
let count = incoming.get_mut(&next).expect("Graph endpoint");
|
||||
*count = count.saturating_sub(1);
|
||||
@@ -1031,6 +1044,29 @@ impl<'a> Graph<'a> {
|
||||
size.height = size.height.max(node.rect.y + node.rect.height + 42.);
|
||||
nodes.push(node);
|
||||
}
|
||||
let ordered_paths = order
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let a = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[0])
|
||||
.expect("ordered source")
|
||||
.rect;
|
||||
let b = nodes
|
||||
.iter()
|
||||
.find(|n| n.id == pair[1])
|
||||
.expect("ordered target")
|
||||
.rect;
|
||||
OrderedPath {
|
||||
source: pair[0],
|
||||
target: pair[1],
|
||||
points: vec![
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut edges = vec![];
|
||||
let mut routed_pairs = BTreeSet::new();
|
||||
for (i, r) in links.into_iter().enumerate() {
|
||||
@@ -1165,6 +1201,8 @@ impl<'a> Graph<'a> {
|
||||
scroll_offset: iced::Vector::ZERO,
|
||||
nodes,
|
||||
edges,
|
||||
ordered_paths,
|
||||
ordered: !order.is_empty(),
|
||||
size,
|
||||
selected,
|
||||
scale: 1.,
|
||||
@@ -1264,6 +1302,18 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
for pass in PAINT_PASSES {
|
||||
match pass {
|
||||
PaintPass::DirectEdges | PaintPass::ContextualEdges => {
|
||||
if pass == PaintPass::DirectEdges {
|
||||
for path in &self.ordered_paths {
|
||||
let selected =
|
||||
path.source == self.selected || path.target == self.selected;
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&path.points,
|
||||
if selected { style::TEAL } else { style::MUTED },
|
||||
if selected { 2.5 } else { 1.5 },
|
||||
);
|
||||
}
|
||||
}
|
||||
for e in self.edges.iter().filter(|edge| {
|
||||
edge.layer
|
||||
== if pass == PaintPass::DirectEdges {
|
||||
@@ -1465,6 +1515,11 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
if cursor.position_in(bounds).is_some_and(|p| {
|
||||
let p = Point::new(p.x / self.scale, p.y / self.scale);
|
||||
self.nodes.iter().any(|n| n.rect.contains(p))
|
||||
|| self.ordered_paths.iter().any(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
crate::diagram::distance_to_segment(p, pair[0], pair[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
|| self.edges.iter().any(|e| {
|
||||
e.label.contains(p)
|
||||
|| e.points
|
||||
|
||||
@@ -238,9 +238,7 @@ impl State {
|
||||
) {
|
||||
return Err("Choose a contribution tool.".into());
|
||||
}
|
||||
if !legacy_order(p, context.owner).is_empty() {
|
||||
return Err("Convert the ordered path to explicit links before drawing.".into());
|
||||
}
|
||||
let order = legacy_order(p, context.owner);
|
||||
let link = insertion
|
||||
.link
|
||||
.map(|id| {
|
||||
@@ -268,6 +266,13 @@ impl State {
|
||||
if let Some(r) = link {
|
||||
d.before = Some(r.source());
|
||||
d.after = Some(r.target());
|
||||
} else if !order.is_empty() {
|
||||
let index = insertion
|
||||
.before
|
||||
.and_then(|id| order.iter().position(|v| *v == id))
|
||||
.unwrap_or(order.len());
|
||||
d.before = index.checked_sub(1).map(|i| order[i]);
|
||||
d.after = order.get(index).copied();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -416,6 +421,13 @@ impl Input {
|
||||
}
|
||||
}
|
||||
impl Graph<'_> {
|
||||
fn ordered_path_at(&self, point: Point) -> Option<&OrderedPath> {
|
||||
self.ordered_paths.iter().find(|path| {
|
||||
path.points.windows(2).any(|pair| {
|
||||
crate::diagram::distance_to_segment(point, pair[0], pair[1]) < 8. / self.scale
|
||||
})
|
||||
})
|
||||
}
|
||||
fn node_at(&self, point: Point) -> Option<Id> {
|
||||
self.nodes
|
||||
.iter()
|
||||
@@ -579,7 +591,9 @@ impl Graph<'_> {
|
||||
link: self
|
||||
.edge_at(point)
|
||||
.filter(|_| self.node_at(point).is_none()),
|
||||
before: self.node_at(point),
|
||||
before: self.node_at(point).or_else(|| {
|
||||
self.ordered_path_at(point).map(|path| path.target)
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -807,6 +821,13 @@ impl Graph<'_> {
|
||||
draw_arrow(f, &e.points, style::TEAL, 3.);
|
||||
}
|
||||
"Insert on this path · preserve its guard"
|
||||
} else if let Some(path) = self.ordered_path_at(point) {
|
||||
draw_arrow(f, &path.points, style::TEAL, 3.);
|
||||
"Insert on ordered path · preserve its order"
|
||||
} else if self.ordered && self.node_at(point).is_some() {
|
||||
"Insert before this contribution · preserve path"
|
||||
} else if self.ordered {
|
||||
"Continue the ordered path"
|
||||
} else if self.node_at(point).is_some() {
|
||||
"Add before this contribution · unconnected"
|
||||
} else {
|
||||
|
||||
@@ -970,6 +970,359 @@ fn direct_process_append_preserves_existing_order_and_rejects_incompatible_excha
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_interactive_owner_view(p: &Project, owner: Id) {
|
||||
use iced::advanced::widget::Tree;
|
||||
fn contains(tree: &Tree, tag: iced::advanced::widget::tree::Tag) -> bool {
|
||||
tree.tag == tag || tree.children.iter().any(|child| contains(child, tag))
|
||||
}
|
||||
let mut app = App::with_project(p.clone());
|
||||
app.layer = p.elements[&owner].layer();
|
||||
app.page = crate::app::Page::Behavior;
|
||||
app.selected = owner;
|
||||
app.behavior.active_owner = Some(owner);
|
||||
let graph: Element<'_, Message> = canvas::Canvas::new(Graph::new(p, owner, owner)).into();
|
||||
let tag = Tree::new(graph.as_widget()).tag;
|
||||
let view = view::view(&app, owner, Column::new());
|
||||
assert!(
|
||||
contains(&Tree::new(view.as_widget()), tag),
|
||||
"every path uses the interactive graph"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_three_ordered_exchanges_preserve_outer_paths_on_insertion() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, next) = empty_exchange_fixture(layer);
|
||||
f.p = execute_batch(
|
||||
&f.p,
|
||||
&[Command::Connect(
|
||||
f.p.relations[&f.exchange].kind(),
|
||||
"Return for reassessment".into(),
|
||||
f.work[2],
|
||||
f.work[0],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let last =
|
||||
f.p.relations
|
||||
.values()
|
||||
.find(|r| r.name == "Return for reassessment")
|
||||
.unwrap()
|
||||
.id;
|
||||
for exchange in [f.exchange, next, last] {
|
||||
let node = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Ordered contribution",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
|
||||
}
|
||||
for index in [0, 2] {
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
let old_order = legacy_order(doc.project(), f.owner);
|
||||
let graph = Graph::new(doc.project(), f.owner, f.owner);
|
||||
assert_eq!(graph.ordered_paths.len(), 2);
|
||||
assert!(graph.edges.is_empty());
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
context(f.owner),
|
||||
Tool::Work,
|
||||
ControlKind::Choice,
|
||||
Insertion {
|
||||
link: None,
|
||||
before: Some(old_order[index]),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: f.work[index],
|
||||
label: "Shared activity".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(state.prepare_pending(doc.project()).unwrap())
|
||||
.unwrap();
|
||||
let added = created(&f.p, doc.project(), f.owner);
|
||||
let mut expected = old_order;
|
||||
expected.insert(index, added);
|
||||
assert_eq!(doc.project().elements[&f.owner].steps().unwrap(), &expected);
|
||||
let links = sequence_links(doc.project(), f.owner);
|
||||
assert_eq!(links.len(), 3);
|
||||
for pair in expected.windows(2) {
|
||||
assert_eq!(
|
||||
links
|
||||
.iter()
|
||||
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
for (id, relation) in &f.p.relations {
|
||||
assert_eq!(&doc.project().relations[id], relation);
|
||||
}
|
||||
let after = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert_eq!(doc.project(), &f.p);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_incompatible_imported_order_rejects_conversion_without_losing_draft() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, _) = empty_exchange_fixture(layer);
|
||||
// Repeated exchange uses have different occurrences, but this legacy path
|
||||
// has an endpoint gap. Converting its implied adjacency must reject.
|
||||
for _ in 0..2 {
|
||||
let node = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Repeated exchange",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
|
||||
}
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
assert_eq!(
|
||||
Graph::new(doc.project(), f.owner, f.owner)
|
||||
.ordered_paths
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
context(f.owner),
|
||||
Tool::Control,
|
||||
ControlKind::Choice,
|
||||
Insertion::default(),
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Name("Retain this proposed decision".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(state.prepare_pending(doc.project()).is_err());
|
||||
assert_eq!(
|
||||
state.draft.as_ref().unwrap().name,
|
||||
"Retain this proposed decision"
|
||||
);
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert_eq!(doc.project(), &f.p);
|
||||
assert!(!doc.undo(), "rejection must not create history");
|
||||
state.update(doc.project(), Event::Cancel).unwrap();
|
||||
assert!(state.draft.is_none());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_empty_first_and_second_exchange_keep_the_same_interactive_renderer() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (f, next) = empty_exchange_fixture(layer);
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
for (index, target) in [f.exchange, next].into_iter().enumerate() {
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
let before = doc.bundle().clone();
|
||||
let mut graph = Graph::new(doc.project(), f.owner, f.owner);
|
||||
let c = context(f.owner);
|
||||
graph.interaction = Some((
|
||||
c,
|
||||
CanvasTool::Add(Tool::Exchange, ControlKind::Choice),
|
||||
true,
|
||||
));
|
||||
let mut input = Input::default();
|
||||
let bounds = Rectangle::with_size(graph.size);
|
||||
let point = Point::new(graph.size.width - 10., graph.size.height - 10.);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
) else {
|
||||
panic!("canvas retains direct exchange insertion");
|
||||
};
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(
|
||||
doc.project(),
|
||||
c,
|
||||
Tool::Exchange,
|
||||
ControlKind::Choice,
|
||||
insertion,
|
||||
)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: target,
|
||||
label: "Exchange".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(state.prepare_pending(doc.project()).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
doc.project().elements[&f.owner].steps().unwrap().len(),
|
||||
index + 1
|
||||
);
|
||||
assert_eq!(sequence_links(doc.project(), f.owner).len(), index);
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
let after = doc.bundle().clone();
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert!(doc.redo());
|
||||
assert_eq!(doc.bundle(), &after);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_imported_order_is_projected_without_mutation_and_first_canvas_edit_is_atomic() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (mut f, next) = empty_exchange_fixture(layer);
|
||||
for exchange in [f.exchange, next] {
|
||||
let node = occurrence(
|
||||
&f.p,
|
||||
f.owner,
|
||||
"Imported exchange",
|
||||
OccurrenceBody::Exchange {
|
||||
operation: None,
|
||||
exchange: Some(exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::default(),
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
},
|
||||
);
|
||||
f.p = execute_batch(&f.p, &[Command::CreateElement(node)]).unwrap();
|
||||
}
|
||||
let mut doc = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
||||
let before = doc.bundle().clone();
|
||||
let order = legacy_order(doc.project(), f.owner);
|
||||
assert_eq!(order.len(), 2);
|
||||
assert_interactive_owner_view(doc.project(), f.owner);
|
||||
let mut graph = Graph::new(doc.project(), f.owner, order[0]);
|
||||
assert!(
|
||||
graph.edges.is_empty(),
|
||||
"projection never invents relation identities"
|
||||
);
|
||||
assert_eq!(graph.ordered_paths.len(), 1);
|
||||
assert!(position(&graph, order[0]).x < position(&graph, order[1]).x);
|
||||
let points = &graph.ordered_paths[0].points;
|
||||
let point = Point::new((points[0].x + points[1].x) / 2., points[0].y);
|
||||
let c = context(f.owner);
|
||||
graph.interaction = Some((c, CanvasTool::Add(Tool::Work, ControlKind::Choice), true));
|
||||
let mut input = Input::default();
|
||||
let bounds = Rectangle::with_size(graph.size);
|
||||
send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
);
|
||||
let Some(Message::TopologyCanvas(CanvasEvent::Begin { insertion, .. })) = send(
|
||||
&graph,
|
||||
&mut input,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
point,
|
||||
bounds,
|
||||
) else {
|
||||
panic!("ordered arrow supports direct insertion");
|
||||
};
|
||||
assert_eq!(
|
||||
insertion,
|
||||
Insertion {
|
||||
link: None,
|
||||
before: Some(order[1])
|
||||
}
|
||||
);
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
let mut state = State::default();
|
||||
state
|
||||
.begin(doc.project(), c, Tool::Work, ControlKind::Choice, insertion)
|
||||
.unwrap();
|
||||
state
|
||||
.update(
|
||||
doc.project(),
|
||||
Event::Target(Choice {
|
||||
id: f.work[1],
|
||||
label: "Respond".into(),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
doc.execute(state.prepare_pending(doc.project()).unwrap())
|
||||
.unwrap();
|
||||
let inserted = created(&f.p, doc.project(), f.owner);
|
||||
assert_eq!(
|
||||
doc.project().elements[&f.owner].steps().unwrap(),
|
||||
&[order[0], inserted, order[1]]
|
||||
);
|
||||
let links = sequence_links(doc.project(), f.owner);
|
||||
assert_eq!(links.len(), 2);
|
||||
for (a, b) in [(order[0], inserted), (inserted, order[1])] {
|
||||
assert!(
|
||||
links
|
||||
.iter()
|
||||
.any(|link| link.source() == a && link.target() == b)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Graph::new(doc.project(), f.owner, inserted)
|
||||
.ordered_paths
|
||||
.is_empty()
|
||||
);
|
||||
for id in f.work {
|
||||
assert_eq!(doc.project().elements[&id], f.p.elements[&id]);
|
||||
}
|
||||
assert!(doc.undo());
|
||||
assert_eq!(doc.bundle(), &before);
|
||||
assert_eq!(doc.project(), &f.p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_process_palette_preserves_fit_and_manual_camera() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
|
||||
@@ -59,21 +59,16 @@ pub(super) fn choice_page(len: usize, requested: usize) -> std::ops::Range<usize
|
||||
let start = requested.min(len.saturating_sub(1) / 24) * 24;
|
||||
start..(start + 24).min(len)
|
||||
}
|
||||
pub(super) fn view<'a>(
|
||||
app: &'a App,
|
||||
owner: Id,
|
||||
mut out: Column<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let context = app.topology_context().expect("visible process context");
|
||||
let state = &app.behavior.topology;
|
||||
let pending = state.draft.is_some();
|
||||
let explicit =
|
||||
uses_explicit_topology(p, owner) || p.elements[&owner].steps().is_none_or(Vec::is_empty);
|
||||
let ordered = !legacy_order(p, owner).is_empty();
|
||||
let selected = is_contribution(p, owner, app.selected).then_some(app.selected);
|
||||
let choose = |tool| Message::TopologyCanvas(CanvasEvent::Choose(context, tool));
|
||||
let mut controls = Column::new().spacing(6);
|
||||
if !pending && explicit {
|
||||
if !pending {
|
||||
controls = controls
|
||||
.push(
|
||||
row![
|
||||
@@ -156,10 +151,22 @@ pub(super) fn view<'a>(
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}else{match state.tool{
|
||||
controls=controls.push(label(if pending{"Pending contribution · apply or cancel below"}else if ordered{"Ordered exchange path · click a path to insert or empty canvas to continue; the edit preserves its order"}else{match state.tool{
|
||||
CanvasTool::Select=>"Select local contributions and paths · drag path endpoints to reconnect · ↕ reorders within a column",
|
||||
CanvasTool::Connect=>"Sequence link · click or drag from preceding to following contribution · Escape cancels",
|
||||
CanvasTool::Add(..)=>"Choose a highlighted path or contribution position · existing work stays shared · Escape cancels"}}));
|
||||
if !pending && ordered {
|
||||
controls = controls.push(button("Append exchange…").on_press(Message::TopologyCanvas(
|
||||
CanvasEvent::AppendExchange(context),
|
||||
)));
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
controls = controls.push(
|
||||
button("Edit path links and guards")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
}
|
||||
}
|
||||
// A fixed sibling keeps Iced's canvas scroll state through selection/form changes.
|
||||
let mut navigation = column![].spacing(6);
|
||||
if let Some(Relation {
|
||||
@@ -249,7 +256,7 @@ pub(super) fn view<'a>(
|
||||
button("Back to caller").on_press(Message::TopologyCanvas(CanvasEvent::Back(context))),
|
||||
);
|
||||
}
|
||||
let diagram = if explicit {
|
||||
let diagram = {
|
||||
let mut graph = Graph::expanded_qualified(
|
||||
p,
|
||||
owner,
|
||||
@@ -373,58 +380,6 @@ pub(super) fn view<'a>(
|
||||
state.shelf.is_some(),
|
||||
pending,
|
||||
)
|
||||
} else {
|
||||
out = out
|
||||
.push(
|
||||
button(if state.focused {
|
||||
"Restore explorer and properties"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::Focus(context))),
|
||||
)
|
||||
.push(controls)
|
||||
.push(navigation);
|
||||
out = out.push(label(
|
||||
"Ordered exchange path · append the next exchange or convert to explicit links.",
|
||||
));
|
||||
if !pending {
|
||||
out = out.push(
|
||||
button("Append exchange…")
|
||||
.on_press(Message::TopologyCanvas(CanvasEvent::AppendExchange(context))),
|
||||
).push(label("Append creates the next exchange contribution and an explicit sequence link from the final contribution in one undo step."));
|
||||
}
|
||||
let commands = preserved_order(p, owner, None);
|
||||
if !commands.is_empty() {
|
||||
out = out.push(
|
||||
button("Convert ordered path to explicit links")
|
||||
.on_press(Message::Command(unique_batch(p, commands))),
|
||||
);
|
||||
} else {
|
||||
out=out.push(label("A single exchange has no path link to convert. Append the next exchange, or involve work, a control or a reusable process."))
|
||||
.push(row![button("Add work…").on_press(event(Event::Start(owner,Tool::Work,selected))),
|
||||
button("Add control…").on_press(event(Event::Start(owner,Tool::Control,selected))),
|
||||
button("Reuse…").on_press(event(Event::Start(owner,Tool::Reuse,selected)))].spacing(6));
|
||||
}
|
||||
let scene = Scene::sequence(p, owner, app.selected);
|
||||
let size = scene.size;
|
||||
out = out.push(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(240.),
|
||||
);
|
||||
super::super::scenario_scroll::observe_topology(
|
||||
context,
|
||||
scenario::ScrollArea::Page,
|
||||
scrollable(out.padding(18)),
|
||||
)
|
||||
};
|
||||
let mut form_panel: Element<'_, Message> = iced::widget::Space::new().height(0).into();
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.owner == owner) {
|
||||
@@ -451,6 +406,17 @@ pub(super) fn view<'a>(
|
||||
p.named(link.target()),
|
||||
guard
|
||||
)));
|
||||
} else if d.fixed && ordered {
|
||||
form = form.push(label(match (d.before, d.after) {
|
||||
(Some(before), Some(after)) => format!(
|
||||
"Insert between {} → {} · preserve the ordered path",
|
||||
p.named(before),
|
||||
p.named(after)
|
||||
),
|
||||
(Some(before), None) => format!("Continue after {}", p.named(before)),
|
||||
(None, Some(after)) => format!("Insert before {}", p.named(after)),
|
||||
_ => "Start the ordered path".into(),
|
||||
}));
|
||||
} else if d.fixed
|
||||
&& d.tool == Tool::Exchange
|
||||
&& let Some(before) = d.before
|
||||
|
||||
@@ -16,6 +16,7 @@ use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod context_fields;
|
||||
mod data_fields;
|
||||
mod history_fields;
|
||||
mod port_fields;
|
||||
mod view_fields;
|
||||
|
||||
@@ -50,6 +51,14 @@ pub(crate) fn set_shape_value(record: &mut Value, path: &str, value: Value) -> R
|
||||
*target = value;
|
||||
return Ok(());
|
||||
}
|
||||
if history_fields::is_path(path)
|
||||
&& history_fields::has_field(path)
|
||||
&& let Some((parent, field)) = path.rsplit_once('/')
|
||||
&& let Some(fields) = record.pointer_mut(parent).and_then(Value::as_object_mut)
|
||||
{
|
||||
fields.insert(field.into(), value);
|
||||
return Ok(());
|
||||
}
|
||||
// Ordinary messages omit operation=None in source/JSON. Authoring that one
|
||||
// optional field must not require changing the historic record encoding.
|
||||
if let Some(parent) = path.strip_suffix("/operation")
|
||||
@@ -75,6 +84,7 @@ pub struct Draft {
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
|
||||
history: history_fields::State,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
@@ -156,6 +166,7 @@ impl Draft {
|
||||
})
|
||||
.collect();
|
||||
draft.port_contract_drafts = saved.ports;
|
||||
draft.history.open = draft.kind == RecordKind::Trace;
|
||||
Ok(draft)
|
||||
}
|
||||
pub fn from_project(project: &Project, selected: Id) -> Option<Self> {
|
||||
@@ -197,6 +208,7 @@ impl Draft {
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
reference_browsers: BTreeMap::new(),
|
||||
history: Default::default(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
@@ -311,9 +323,38 @@ impl Draft {
|
||||
.map_or(true, |value| value != self.baseline)
|
||||
}
|
||||
pub fn set_exact(&mut self, enabled: bool) -> Result<(), String> {
|
||||
self.value = self.materialize()?;
|
||||
let value = self.materialize()?;
|
||||
let mut history_inputs = Vec::new();
|
||||
if enabled != self.exact_strings {
|
||||
for (key, (_, inputs)) in &self.constructor_drafts {
|
||||
if self.kind != RecordKind::Trace || !history_fields::is_path(&key.0) {
|
||||
continue;
|
||||
}
|
||||
for (path, input) in inputs {
|
||||
if free_string(path) {
|
||||
let raw = if enabled {
|
||||
serde_json::to_string(&input.raw).expect("String serialization")
|
||||
} else {
|
||||
serde_json::from_str::<String>(&input.raw).map_err(|_| "Repair the quoted text in the retained history constructor before changing escaped-string mode.".to_owned())?
|
||||
};
|
||||
history_inputs.push((key.clone(), path.clone(), raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.value = value;
|
||||
self.exact_strings = enabled;
|
||||
self.rebuild();
|
||||
for (key, path, raw) in history_inputs {
|
||||
if let Some(input) = self
|
||||
.constructor_drafts
|
||||
.get_mut(&key)
|
||||
.and_then(|(_, inputs)| inputs.get_mut(&path))
|
||||
{
|
||||
input.editor = text_editor::Content::with_text(&raw);
|
||||
input.raw = raw;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn input(&mut self, path: &str, value: String) {
|
||||
@@ -329,6 +370,29 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
if self.kind == RecordKind::Trace {
|
||||
if path == history_fields::TOGGLE {
|
||||
self.history.open = value
|
||||
.as_bool()
|
||||
.ok_or("Choose whether to show stored history.")?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = path.strip_prefix(history_fields::PAGE) {
|
||||
self.history.pages.insert(
|
||||
path.into(),
|
||||
value.as_u64().ok_or("Choose a history page.")? as usize,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(path) = path.strip_prefix(history_fields::VARIANT) {
|
||||
return self.variant_scoped(path, value);
|
||||
}
|
||||
if history_fields::is_path(path) {
|
||||
self.constructor_drafts
|
||||
.retain(|(root, _), _| !within(root, path));
|
||||
return self.shape_scoped(path, value);
|
||||
}
|
||||
}
|
||||
let mut candidate = self.materialize()?;
|
||||
let value = if path.ends_with("/Port/contract") {
|
||||
port_fields::switch_contract(
|
||||
@@ -369,7 +433,8 @@ impl Draft {
|
||||
|| path.ends_with("/Occurrence/body")
|
||||
|| path.ends_with("/Event/body")
|
||||
|| path.ends_with("/message_kind")
|
||||
|| path.ends_with("/ContextualLink/purpose");
|
||||
|| path.ends_with("/ContextualLink/purpose")
|
||||
|| (self.kind == RecordKind::Trace && history_fields::is_path(path));
|
||||
if !constructor {
|
||||
return self.shape_scoped(path, proposed);
|
||||
}
|
||||
@@ -448,7 +513,7 @@ impl Draft {
|
||||
.len();
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
self.rebuild_retaining(|old| {
|
||||
let relocate = |old: &str| {
|
||||
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
|
||||
return Some(old.to_owned());
|
||||
};
|
||||
@@ -474,7 +539,24 @@ impl Draft {
|
||||
} else {
|
||||
format!("{path}/{position}/{rest}")
|
||||
})
|
||||
});
|
||||
};
|
||||
self.rebuild_retaining(relocate);
|
||||
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
|
||||
let previous = std::mem::take(&mut self.constructor_drafts);
|
||||
for ((root, tag), (value, inputs)) in previous {
|
||||
if !root.starts_with(&format!("{path}/")) {
|
||||
self.constructor_drafts.insert((root, tag), (value, inputs));
|
||||
continue;
|
||||
}
|
||||
if let Some(root) = relocate(&root) {
|
||||
let inputs = inputs
|
||||
.into_iter()
|
||||
.filter_map(|(path, input)| relocate(&path).map(|path| (path, input)))
|
||||
.collect();
|
||||
self.constructor_drafts.insert((root, tag), (value, inputs));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
@@ -510,6 +592,9 @@ impl Draft {
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
|
||||
return self.array_scoped(path, index, direction);
|
||||
}
|
||||
let mut candidate = self.materialize()?;
|
||||
edit_array(&mut candidate, path, index, direction)?;
|
||||
self.value = candidate;
|
||||
@@ -517,6 +602,9 @@ impl Draft {
|
||||
Ok(())
|
||||
}
|
||||
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
|
||||
return self.append_scoped(path, value);
|
||||
}
|
||||
let mut candidate = self.materialize()?;
|
||||
candidate
|
||||
.pointer_mut(path)
|
||||
@@ -625,6 +713,9 @@ fn edit_array(
|
||||
index: Option<usize>,
|
||||
direction: i32,
|
||||
) -> Result<(), String> {
|
||||
if history_fields::is_path(path) && index.is_none() {
|
||||
return Err("Choose a typed stored fact or list value before adding it.".into());
|
||||
}
|
||||
let items = value
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
@@ -807,6 +898,9 @@ fn multiline(path: &str) -> bool {
|
||||
)
|
||||
}
|
||||
fn free_string(path: &str) -> bool {
|
||||
if history_fields::is_path(path) {
|
||||
return history_fields::free_string(path);
|
||||
}
|
||||
multiline(path)
|
||||
|| matches!(
|
||||
path.rsplit('/').next(),
|
||||
@@ -1728,6 +1822,9 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if draft.kind == RecordKind::Trace && path == "/baseline" {
|
||||
return history_fields::view(draft, project, value);
|
||||
}
|
||||
if let Some(view) = context_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,666 @@
|
||||
//! Typed editing of stored observations, independently of the live counterparts.
|
||||
//! The checked-in, contract-tested OpenAPI descriptor supplies only field shapes.
|
||||
//! Serde and core historical validation remain the admission authority.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub(super) const TOGGLE: &str = "/$stored_history";
|
||||
pub(super) const PAGE: &str = "/$stored_history_page";
|
||||
pub(super) const VARIANT: &str = "/$stored_history_variant";
|
||||
const ROOT: &str = "/baseline";
|
||||
const FACETS: &str = "/baseline/semantic/facets";
|
||||
const PAGE_SIZE: usize = 12;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
pub open: bool,
|
||||
pub pages: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
pub(super) fn is_path(path: &str) -> bool {
|
||||
path == ROOT || path.starts_with("/baseline/")
|
||||
}
|
||||
|
||||
fn api() -> &'static Value {
|
||||
static API: OnceLock<Value> = OnceLock::new();
|
||||
API.get_or_init(|| {
|
||||
serde_json::from_str(include_str!("../../../../docs/openapi.json"))
|
||||
.expect("The bundled OpenAPI descriptor is valid JSON")
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve(mut schema: &'static Value) -> Result<&'static Value, &'static str> {
|
||||
for _ in 0..MAX_DEPTH {
|
||||
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
|
||||
return Ok(schema);
|
||||
};
|
||||
schema = reference
|
||||
.strip_prefix('#')
|
||||
.and_then(|path| api().pointer(path))
|
||||
.ok_or("Stored-history descriptor has an unresolved local reference.")?;
|
||||
}
|
||||
Err("Stored-history descriptor reference depth exceeded.")
|
||||
}
|
||||
|
||||
fn named(name: &str) -> Result<&'static Value, &'static str> {
|
||||
api()["components"]["schemas"]
|
||||
.get(name)
|
||||
.ok_or("Stored-history descriptor is missing a required type.")
|
||||
}
|
||||
|
||||
fn alternatives(schema: &'static Value) -> Option<&'static Vec<Value>> {
|
||||
schema
|
||||
.get("oneOf")
|
||||
.or_else(|| schema.get("anyOf"))
|
||||
.and_then(Value::as_array)
|
||||
}
|
||||
|
||||
fn at_schema(schema: &'static Value, parts: &[&str], depth: usize) -> Option<&'static Value> {
|
||||
if depth > MAX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let schema = resolve(schema).ok()?;
|
||||
let Some((part, rest)) = parts.split_first() else {
|
||||
return Some(schema);
|
||||
};
|
||||
if let Some(options) = alternatives(schema) {
|
||||
return options.iter().find_map(|s| at_schema(s, parts, depth + 1));
|
||||
}
|
||||
match schema.get("type").and_then(Value::as_str) {
|
||||
Some("object") => at_schema(schema.get("properties")?.get(*part)?, rest, depth + 1),
|
||||
Some("array") if part.parse::<usize>().is_ok() => {
|
||||
at_schema(schema.get("items")?, rest, depth + 1)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_at(path: &str) -> Option<&'static Value> {
|
||||
let tail = path.strip_prefix(ROOT)?;
|
||||
let parts: Vec<_> = tail
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
at_schema(named("TraceBaseline").ok()?, &parts, 0)
|
||||
}
|
||||
|
||||
pub(super) fn has_field(path: &str) -> bool {
|
||||
schema_at(path).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn free_string(path: &str) -> bool {
|
||||
fn text_shape(schema: &'static Value, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let Ok(schema) = resolve(schema) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(options) = alternatives(schema) {
|
||||
return options.iter().any(|s| text_shape(s, depth + 1));
|
||||
}
|
||||
schema.get("type").and_then(Value::as_str) == Some("string")
|
||||
&& schema.get("enum").is_none()
|
||||
&& schema.get("format").and_then(Value::as_str) != Some("uuid")
|
||||
}
|
||||
schema_at(path).is_some_and(|s| text_shape(s, 0))
|
||||
}
|
||||
|
||||
/// A shape seed is a draft, never an acknowledged historical fact. UUIDs start
|
||||
/// empty. Observed starts NA on both sides, which core rejects. Typed defaults
|
||||
/// inside Value are introduced only after the engineer chooses that branch.
|
||||
fn seed(schema: &'static Value, depth: usize) -> Result<Value, &'static str> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("Stored-history constructor depth exceeded.");
|
||||
}
|
||||
let schema = resolve(schema)?;
|
||||
if let Some(options) = alternatives(schema) {
|
||||
if options
|
||||
.iter()
|
||||
.any(|s| matches_value(s, &json!("NotApplicable"), 0))
|
||||
{
|
||||
return Ok(json!("NotApplicable"));
|
||||
}
|
||||
if options.iter().any(|s| matches_value(s, &Value::Null, 0)) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
return seed(
|
||||
options
|
||||
.first()
|
||||
.ok_or("Stored-history union has no constructors.")?,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||||
return values
|
||||
.first()
|
||||
.cloned()
|
||||
.ok_or("Stored-history enumeration has no values.");
|
||||
}
|
||||
Ok(match schema.get("type").and_then(Value::as_str) {
|
||||
Some("object") => {
|
||||
let fields = schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or("Stored-history object has no closed field descriptor.")?;
|
||||
let mut result = serde_json::Map::new();
|
||||
for (name, schema) in fields {
|
||||
result.insert(name.clone(), seed(schema, depth + 1)?);
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
Some("array") => json!([]),
|
||||
Some("boolean") => json!(false),
|
||||
Some("integer") => json!(0),
|
||||
Some("number") => json!(0.0),
|
||||
Some("string") => json!(""),
|
||||
Some("null") => Value::Null,
|
||||
_ => return Err("Unsupported stored-history field descriptor."),
|
||||
})
|
||||
}
|
||||
|
||||
/// Shape matching selects the displayed constructor; it is not validation.
|
||||
/// Required values may be incomplete while their chosen branch stays editable.
|
||||
fn matches_value(schema: &'static Value, value: &Value, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
return false;
|
||||
}
|
||||
let Ok(schema) = resolve(schema) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(options) = alternatives(schema) {
|
||||
return options.iter().any(|s| matches_value(s, value, depth + 1));
|
||||
}
|
||||
if let Some(choices) = schema.get("enum").and_then(Value::as_array) {
|
||||
return choices.contains(value);
|
||||
}
|
||||
match schema.get("type").and_then(Value::as_str) {
|
||||
Some("object") => value.as_object().is_some_and(|fields| {
|
||||
schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|properties| {
|
||||
fields.keys().all(|key| properties.contains_key(key))
|
||||
&& schema
|
||||
.get("required")
|
||||
.and_then(Value::as_array)
|
||||
.is_none_or(|required| {
|
||||
required
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.all(|key| fields.contains_key(key))
|
||||
})
|
||||
})
|
||||
}),
|
||||
Some("array") => value.is_array(),
|
||||
Some("boolean") => value.is_boolean(),
|
||||
Some("integer" | "number") => value.is_number(),
|
||||
Some("string") => value.is_string(),
|
||||
Some("null") => value.is_null(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(name: &str) -> String {
|
||||
match name {
|
||||
"oa" => return "Operational observation".into(),
|
||||
"sa" => return "System observation".into(),
|
||||
"NotApplicable" => return "Not applicable".into(),
|
||||
"Value" => return "Observed value".into(),
|
||||
"keys" => return "Exact historical keys".into(),
|
||||
"review" => return "Stored observations".into(),
|
||||
_ => {}
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut previous_lower = false;
|
||||
for c in name.chars() {
|
||||
if c == '_' {
|
||||
out.push(' ');
|
||||
} else {
|
||||
if c.is_uppercase() && previous_lower {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
previous_lower = c.is_lowercase();
|
||||
}
|
||||
let mut chars = out.chars();
|
||||
chars
|
||||
.next()
|
||||
.map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn branch_label(schema: &'static Value) -> String {
|
||||
let Ok(schema) = resolve(schema) else {
|
||||
return "Unavailable type".into();
|
||||
};
|
||||
if let Some(word) = schema
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|v| v.len() == 1)
|
||||
.and_then(|v| v[0].as_str())
|
||||
{
|
||||
return label(word);
|
||||
}
|
||||
if schema.get("enum").is_some() {
|
||||
return "Enumeration · choose below".into();
|
||||
}
|
||||
if let Some(fields) = schema.get("properties").and_then(Value::as_object)
|
||||
&& fields.len() == 1
|
||||
{
|
||||
return label(fields.keys().next().unwrap());
|
||||
}
|
||||
match schema.get("type").and_then(Value::as_str) {
|
||||
Some("null") => "Absent (none)".into(),
|
||||
Some("object") => "Set record".into(),
|
||||
Some("array") => "Set list".into(),
|
||||
Some("string") if schema.get("format").and_then(Value::as_str) == Some("uuid") => {
|
||||
"Set historical UUID".into()
|
||||
}
|
||||
Some("string") => "Set text".into(),
|
||||
Some("integer" | "number") => "Set number".into(),
|
||||
_ => "Set value / choose constructor".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct FactChoice {
|
||||
label: String,
|
||||
value: Value,
|
||||
}
|
||||
impl std::fmt::Display for FactChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten only the closed FacetReview selector levels, not observation values.
|
||||
/// Collection and incident-end choices are selectors too, so none is guessed.
|
||||
fn fact_choices() -> Result<&'static Vec<FactChoice>, &'static str> {
|
||||
static CHOICES: OnceLock<Result<Vec<FactChoice>, &'static str>> = OnceLock::new();
|
||||
CHOICES
|
||||
.get_or_init(|| {
|
||||
let root = resolve(named("FacetReview")?)?;
|
||||
let mut result = vec![];
|
||||
for branch in alternatives(root).ok_or("FacetReview must be a closed union.")? {
|
||||
let outer = resolve(branch)?;
|
||||
let (family, payload) = outer["properties"]
|
||||
.as_object()
|
||||
.and_then(|v| v.iter().next())
|
||||
.ok_or("Facet family is missing.")?;
|
||||
let payload = resolve(payload)?;
|
||||
if let Some(leaves) = alternatives(payload) {
|
||||
for leaf in leaves {
|
||||
let mut value = serde_json::Map::new();
|
||||
value.insert(family.clone(), seed(leaf, 0)?);
|
||||
result.push(FactChoice {
|
||||
label: format!("{} / {}", label(family), branch_label(leaf)),
|
||||
value: Value::Object(value),
|
||||
});
|
||||
}
|
||||
} else if family == "Artifact" {
|
||||
let leaves = alternatives(resolve(&payload["properties"]["review"])?)
|
||||
.ok_or("Artifact facts must be a closed union.")?;
|
||||
for leaf in leaves {
|
||||
let mut value = seed(branch, 0)?;
|
||||
value[family]["review"] = seed(leaf, 0)?;
|
||||
result.push(FactChoice {
|
||||
label: format!("Artifact / {}", branch_label(leaf)),
|
||||
value,
|
||||
});
|
||||
}
|
||||
} else if matches!(family.as_str(), "Member" | "Order" | "Incident") {
|
||||
let field = if family == "Incident" {
|
||||
"end"
|
||||
} else {
|
||||
"collection"
|
||||
};
|
||||
let choices = resolve(&payload["properties"][field])?["enum"]
|
||||
.as_array()
|
||||
.ok_or("Historical selector choices are missing.")?;
|
||||
for choice in choices {
|
||||
let mut value = seed(branch, 0)?;
|
||||
value[family][field] = choice.clone();
|
||||
result.push(FactChoice {
|
||||
label: format!(
|
||||
"{} / {}",
|
||||
label(family),
|
||||
label(choice.as_str().unwrap_or(""))
|
||||
),
|
||||
value,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
result.push(FactChoice {
|
||||
label: label(family),
|
||||
value: seed(branch, 0)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|error| *error)
|
||||
}
|
||||
|
||||
fn variant_message(path: &str, value: Value, present: bool) -> Message {
|
||||
Message::FieldShape(
|
||||
if present {
|
||||
format!("{VARIANT}{path}")
|
||||
} else {
|
||||
path.into()
|
||||
},
|
||||
value,
|
||||
)
|
||||
}
|
||||
|
||||
fn branch_seed(schema: &'static Value) -> Result<Value, &'static str> {
|
||||
let schema = resolve(schema)?;
|
||||
if let Some(values) = schema
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|v| v.len() == 1)
|
||||
{
|
||||
Ok(values[0].clone())
|
||||
} else {
|
||||
seed(schema, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fn branch_has_fields(schema: &'static Value) -> bool {
|
||||
let Ok(schema) = resolve(schema) else {
|
||||
return false;
|
||||
};
|
||||
schema.get("type").and_then(Value::as_str) != Some("null")
|
||||
&& schema
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.is_none_or(|values| values.len() > 1)
|
||||
}
|
||||
|
||||
fn paging<'a>(draft: &'a Draft, path: &str, count: usize) -> (usize, View<'a, Message>) {
|
||||
let last = count.saturating_sub(1) / PAGE_SIZE;
|
||||
let page = draft
|
||||
.history
|
||||
.pages
|
||||
.get(path)
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
.min(last);
|
||||
let event = |page| Message::FieldShape(format!("{PAGE}{path}"), json!(page));
|
||||
let view = row![
|
||||
button("Previous").on_press_maybe((page > 0).then(|| event(page - 1))),
|
||||
text(format!(
|
||||
"{}–{} of {count}",
|
||||
if count == 0 { 0 } else { page * PAGE_SIZE + 1 },
|
||||
((page + 1) * PAGE_SIZE).min(count)
|
||||
))
|
||||
.size(11),
|
||||
button("Next").on_press_maybe((page < last).then(|| event(page + 1)))
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center)
|
||||
.into();
|
||||
(page, view)
|
||||
}
|
||||
|
||||
fn render<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
schema: &'static Value,
|
||||
value: Option<&'a Value>,
|
||||
path: &str,
|
||||
title: &str,
|
||||
depth: usize,
|
||||
) -> View<'a, Message> {
|
||||
if depth > MAX_DEPTH {
|
||||
return text("Stored-history field nesting exceeds the supported editor depth.")
|
||||
.size(11)
|
||||
.into();
|
||||
}
|
||||
let schema = match resolve(schema) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return text(e).size(11).into(),
|
||||
};
|
||||
let present = value.is_some();
|
||||
let value = value.unwrap_or(&Value::Null);
|
||||
if let Some(options) = alternatives(schema) {
|
||||
let initial: Vec<_> = match options.iter().map(branch_seed).collect::<Result<_, _>>() {
|
||||
Ok(values) => values,
|
||||
Err(error) => {
|
||||
return text(format!(
|
||||
"Stored-history constructor is unavailable: {error}"
|
||||
))
|
||||
.size(11)
|
||||
.into();
|
||||
}
|
||||
};
|
||||
let labels: Vec<_> = options.iter().map(branch_label).collect();
|
||||
let selected = options.iter().position(|s| matches_value(s, value, 0));
|
||||
let path_owned = path.to_string();
|
||||
let labels_owned = labels.clone();
|
||||
let mut out = column![
|
||||
text(title.to_owned()).size(11).color(style::MUTED),
|
||||
pick_list(
|
||||
labels,
|
||||
selected.map(|i| branch_label(&options[i])),
|
||||
move |choice| {
|
||||
let index = labels_owned.iter().position(|v| v == &choice).unwrap();
|
||||
variant_message(&path_owned, initial[index].clone(), present)
|
||||
}
|
||||
)
|
||||
.placeholder("Choose stored value / constructor…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(6);
|
||||
if let Some(index) = selected {
|
||||
let branch = resolve(&options[index]).unwrap_or(&options[index]);
|
||||
if branch_has_fields(branch) {
|
||||
out = out.push(render(
|
||||
draft,
|
||||
project,
|
||||
branch,
|
||||
Some(value),
|
||||
path,
|
||||
"",
|
||||
depth + 1,
|
||||
));
|
||||
}
|
||||
}
|
||||
return out.into();
|
||||
}
|
||||
if let Some(words) = schema.get("enum").and_then(Value::as_array) {
|
||||
let choices: Vec<_> = words
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let selected = value
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.filter(|v| choices.contains(v));
|
||||
let path = path.to_string();
|
||||
return column![
|
||||
text(title.to_owned()).size(11).color(style::MUTED),
|
||||
pick_list(choices, selected, move |word| Message::FieldShape(
|
||||
path.clone(),
|
||||
json!(word)
|
||||
))
|
||||
.placeholder("Choose an explicit value…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(6)
|
||||
.into();
|
||||
}
|
||||
match schema.get("type").and_then(Value::as_str) {
|
||||
Some("object") => {
|
||||
let Some(fields) = schema.get("properties").and_then(Value::as_object) else {
|
||||
return text("Unsupported stored-history object shape.").size(11).into();
|
||||
};
|
||||
let mut out = column![].spacing(8);
|
||||
if !title.is_empty() { out = out.push(text(title.to_owned()).size(12).color(style::INK)); }
|
||||
for (key, child_schema) in fields {
|
||||
out = out.push(render(draft, project, child_schema, value.get(key), &format!("{path}/{key}"), &label(key), depth + 1));
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
Some("array") => {
|
||||
let Some(items) = value.as_array() else {
|
||||
return button("Initialize empty list").on_press(Message::FieldShape(path.into(), json!([]))).into();
|
||||
};
|
||||
let Some(item_schema) = schema.get("items") else { return text("Missing history list item type.").into(); };
|
||||
let mut out = column![text(title.to_owned()).size(12)].spacing(8);
|
||||
if path == FACETS {
|
||||
match fact_choices() {
|
||||
Ok(choices) => {
|
||||
let path = path.to_owned();
|
||||
out = out.push(pick_list(choices.clone(), None::<FactChoice>, move |choice| Message::FieldAppend(path.clone(), choice.value))
|
||||
.placeholder("Add: choose family and fact…").text_size(12).padding(8).width(Fill));
|
||||
}
|
||||
Err(error) => { out = out.push(text(error).size(11)); }
|
||||
}
|
||||
} else {
|
||||
match seed(item_schema, 0) {
|
||||
Ok(initial) => out = out.push(button("Add list value").on_press(Message::FieldAppend(path.into(), initial))),
|
||||
Err(error) => out = out.push(text(error).size(11)),
|
||||
}
|
||||
}
|
||||
let (page, pager) = paging(draft, path, items.len());
|
||||
if items.len() > PAGE_SIZE { out = out.push(pager); }
|
||||
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
|
||||
let item_path = format!("{path}/{index}");
|
||||
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
|
||||
if path == FACETS {
|
||||
if let Some(current) = current_observation(draft, project, &item_path) {
|
||||
card = card.push(button("Use current counterpart observations")
|
||||
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
|
||||
}
|
||||
}
|
||||
card = card.push(row![
|
||||
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
|
||||
button("↓").on_press_maybe((index + 1 < items.len()).then(|| Message::FieldArray(path.into(), Some(index), 1))),
|
||||
button("Remove").on_press(Message::FieldArray(path.into(), Some(index), 0))
|
||||
].spacing(6));
|
||||
out = out.push(container(card).padding(10).style(style::subtle));
|
||||
}
|
||||
if items.is_empty() { out = out.push(text("Empty list · no stored entries").size(11).color(style::MUTED)); }
|
||||
out.into()
|
||||
}
|
||||
Some("boolean") => {
|
||||
let path = path.to_owned();
|
||||
checkbox(value.as_bool().unwrap_or(false)).label(title.to_owned())
|
||||
.on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))).text_size(12).into()
|
||||
}
|
||||
Some("integer" | "number" | "string") => {
|
||||
let is_uuid = schema.get("format").and_then(Value::as_str) == Some("uuid");
|
||||
let numeric = schema.get("type").and_then(Value::as_str) != Some("string");
|
||||
let mut out = column![text(title.to_owned()).size(11).color(style::MUTED)].spacing(5);
|
||||
if let Some(input) = draft.inputs.get(path) {
|
||||
let owned = path.to_owned();
|
||||
if !numeric && !is_uuid && !draft.exact_strings {
|
||||
out = out.push(text_editor(&input.editor).on_action(move |a| Message::FieldText(owned.clone(), a)).size(12).height(72).padding(8));
|
||||
} else {
|
||||
out = out.push(text_input(if is_uuid { "Historical UUID, including deleted identities" } else { "Stored value" }, &input.raw)
|
||||
.on_input(move |raw| Message::Field(owned.clone(), raw)).size(12).padding(8));
|
||||
}
|
||||
} else {
|
||||
match seed(schema, 0) {
|
||||
Ok(value) => out = out.push(button("Set stored value").on_press(Message::FieldShape(path.into(), value))),
|
||||
Err(error) => out = out.push(text(error).size(11)),
|
||||
}
|
||||
}
|
||||
if is_uuid { out = out.push(text("Historical identity · existence in the current model is not required.").size(10).color(style::MUTED)); }
|
||||
out.into()
|
||||
}
|
||||
Some("null") => text("Absent (none)").size(11).into(),
|
||||
_ => text("Unsupported stored-history descriptor. The draft is retained; this field cannot be edited with these controls.").size(11).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_observation(draft: &Draft, project: &Project, item_path: &str) -> Option<FacetReview> {
|
||||
let raw = draft
|
||||
.materialize_scopes(&[item_path.to_owned(), "/oa".into(), "/sa".into()])
|
||||
.ok()?;
|
||||
let fact: FacetReview = serde_json::from_value(raw.pointer(item_path)?.clone()).ok()?;
|
||||
let oa = raw.get("oa")?.as_str()?.parse().ok()?;
|
||||
let sa = raw.get("sa")?.as_str()?.parse().ok()?;
|
||||
semantic::capture_facet(project, oa, sa, &fact.selector()).ok()
|
||||
}
|
||||
|
||||
pub(super) fn view<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
value: &'a Value,
|
||||
) -> View<'a, Message> {
|
||||
let count = value
|
||||
.pointer("/semantic/facets")
|
||||
.and_then(Value::as_array)
|
||||
.map_or(0, Vec::len);
|
||||
let mut out = column![
|
||||
text("Stored review history").size(12).color(style::INK),
|
||||
text(if value.is_null() {
|
||||
"No stored baseline".into()
|
||||
} else {
|
||||
format!("Prose baseline · {count} semantic observations")
|
||||
})
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
button(if draft.history.open {
|
||||
"Close stored-history editor"
|
||||
} else {
|
||||
"Edit stored history (advanced)…"
|
||||
})
|
||||
.on_press(Message::FieldShape(
|
||||
TOGGLE.into(),
|
||||
json!(!draft.history.open)
|
||||
))
|
||||
.style(style::nav)
|
||||
]
|
||||
.spacing(8);
|
||||
if !draft.history.open {
|
||||
return out.into();
|
||||
}
|
||||
out = out.push(text("These edits change the trace's stored observations only. They do not change either counterpart. Ordinary Keep/Take review remains available from Traceability.").size(11));
|
||||
if value.is_null() {
|
||||
return match named("TraceBaseline").and_then(|s| seed(s, 0)) {
|
||||
Ok(initial) => out
|
||||
.push(
|
||||
button("Start empty stored baseline")
|
||||
.on_press(Message::FieldShape(ROOT.into(), initial)),
|
||||
)
|
||||
.into(),
|
||||
Err(error) => out.push(text(error).size(11)).into(),
|
||||
};
|
||||
}
|
||||
out = out.push(
|
||||
button("Clear whole stored baseline")
|
||||
.on_press(Message::FieldShape(ROOT.into(), Value::Null))
|
||||
.style(style::nav),
|
||||
);
|
||||
match named("TraceBaseline") {
|
||||
Ok(schema) => out = out.push(render(draft, project, schema, Some(value), ROOT, "", 0)),
|
||||
Err(error) => out = out.push(text(error).size(11)),
|
||||
}
|
||||
let checked = draft.materialize_scopes(&[ROOT.into()]).and_then(|raw| {
|
||||
let baseline: TraceBaseline =
|
||||
serde_json::from_value(raw["baseline"].clone()).map_err(|e| e.to_string())?;
|
||||
baseline.semantic.as_ref().map_or(Ok(()), |b| {
|
||||
semantic::validate_semantic_baseline(b).map_err(|e| e.join("; "))
|
||||
})
|
||||
});
|
||||
if let Err(error) = checked {
|
||||
out = out.push(text(format!("Stored history is incomplete or invalid: {error}")).size(11));
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,733 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
|
||||
fn fixture(baseline: Option<TraceBaseline>) -> (Project, Id) {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess need", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess request", None)
|
||||
.unwrap();
|
||||
let id = Id::from_u128(9001);
|
||||
project.traces.insert(
|
||||
id,
|
||||
Trace {
|
||||
id,
|
||||
alias: "assessment_review".into(),
|
||||
name: "Assessment correspondence".into(),
|
||||
description: "Engineering review history".into(),
|
||||
oa,
|
||||
sa,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline,
|
||||
},
|
||||
);
|
||||
validate_candidate(&project).unwrap();
|
||||
(project, id)
|
||||
}
|
||||
|
||||
fn baseline(facets: Vec<FacetReview>) -> TraceBaseline {
|
||||
TraceBaseline {
|
||||
oa: TracedProperties {
|
||||
name: "Earlier OA name".into(),
|
||||
description: "OA note".into(),
|
||||
},
|
||||
sa: TracedProperties {
|
||||
name: "Earlier SA name".into(),
|
||||
description: "SA note".into(),
|
||||
},
|
||||
semantic: Some(SemanticBaseline { facets }),
|
||||
}
|
||||
}
|
||||
|
||||
fn document(project: &Project) -> Document {
|
||||
let mut bundle = source_project::format_bundle(project);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.insert_str(0, "// Retain this authored project note.\n\n");
|
||||
Document::from_bundle(bundle, Default::default()).unwrap()
|
||||
}
|
||||
|
||||
fn choice(selector: FacetSelector) -> Value {
|
||||
fact_choices()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|choice| {
|
||||
serde_json::from_value::<FacetReview>(choice.value.clone())
|
||||
.unwrap()
|
||||
.selector()
|
||||
== selector
|
||||
})
|
||||
.unwrap()
|
||||
.value
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn add_header(draft: &mut Draft) {
|
||||
draft
|
||||
.append(FACETS, choice(FacetSelector::Header(HeaderFacet::Name)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn assert_history_transaction(document: &mut Document, draft: &Draft, expected: &Project) {
|
||||
let before = document.project().clone();
|
||||
let source_before = document.bundle().clone();
|
||||
document.execute(draft.command().unwrap()).unwrap();
|
||||
assert_eq!(document.project(), expected);
|
||||
let accepted_source = document.bundle().clone();
|
||||
assert!(
|
||||
accepted_source.files[&accepted_source.entry]
|
||||
.starts_with("// Retain this authored project note.\n\n")
|
||||
);
|
||||
assert_eq!(
|
||||
Document::from_bundle(accepted_source.clone(), Default::default())
|
||||
.unwrap()
|
||||
.project(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Project>(&serde_json::to_vec(expected).unwrap()).unwrap(),
|
||||
*expected
|
||||
);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &before);
|
||||
assert_eq!(document.bundle(), &source_before);
|
||||
assert!(
|
||||
!document.undo(),
|
||||
"one stored-history Apply must create one history entry"
|
||||
);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), expected);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
assert!(!document.redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_absent_baseline_and_empty_history_use_explicit_facts_and_exact_document_history() {
|
||||
for initial in [None, Some(baseline(vec![]))] {
|
||||
let (project, id) = fixture(initial);
|
||||
let mut doc = document(&project);
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
assert!(!draft.history.open);
|
||||
draft.shape(TOGGLE, json!(true)).unwrap();
|
||||
assert!(draft.history.open);
|
||||
assert!(
|
||||
!draft.dirty(),
|
||||
"opening the advanced editor cannot acknowledge history"
|
||||
);
|
||||
if draft.value["baseline"].is_null() {
|
||||
draft
|
||||
.shape(ROOT, seed(named("TraceBaseline").unwrap(), 0).unwrap())
|
||||
.unwrap();
|
||||
draft
|
||||
.shape("/baseline/semantic", json!({"facets":[]}))
|
||||
.unwrap();
|
||||
}
|
||||
let untouched = draft.value.clone();
|
||||
assert!(
|
||||
draft
|
||||
.array(FACETS, None, 0)
|
||||
.unwrap_err()
|
||||
.contains("Choose a typed")
|
||||
);
|
||||
assert_eq!(
|
||||
draft.value, untouched,
|
||||
"the old nil-UUID Add must not mutate the draft"
|
||||
);
|
||||
add_header(&mut draft);
|
||||
let before = doc.snapshot();
|
||||
assert!(
|
||||
doc.execute(draft.command().unwrap()).is_err(),
|
||||
"NA/NA is incomplete, not a reviewed default"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &doc.snapshot()));
|
||||
let current = current_observation(&draft, &project, &format!("{FACETS}/0")).unwrap();
|
||||
assert_eq!(
|
||||
current,
|
||||
semantic::capture_facet(
|
||||
&project,
|
||||
project.traces[&id].oa,
|
||||
project.traces[&id].sa,
|
||||
&FacetSelector::Header(HeaderFacet::Name)
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
draft.shape(&format!("{FACETS}/0"), json!(current)).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected.traces.get_mut(&id).unwrap().baseline =
|
||||
Some(serde_json::from_value(draft.materialize().unwrap()["baseline"].clone()).unwrap());
|
||||
assert_history_transaction(&mut doc, &draft, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_missing_semantic_field_can_be_added_and_removed_without_touching_prose_or_live_objects()
|
||||
{
|
||||
let mut prose = baseline(vec![]);
|
||||
prose.semantic = None;
|
||||
let (project, id) = fixture(Some(prose));
|
||||
let mut doc = document(&project);
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
assert!(draft.value.pointer("/baseline/semantic").is_none());
|
||||
draft
|
||||
.shape("/baseline/semantic", json!({"facets":[]}))
|
||||
.unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline::default());
|
||||
assert_history_transaction(&mut doc, &draft, &expected);
|
||||
let mut restored = Draft::from_project(&expected, id).unwrap();
|
||||
restored.shape("/baseline/semantic", Value::Null).unwrap();
|
||||
assert_eq!(
|
||||
execute_batch(&expected, &[restored.command().unwrap()]).unwrap(),
|
||||
project
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate type witnesses independently of the editor's deliberately incomplete
|
||||
/// seeds, then ask the real closed Rust types and historical validator to admit them.
|
||||
fn witness(schema: &'static Value, active: &Value, depth: usize) -> Value {
|
||||
assert!(
|
||||
depth <= MAX_DEPTH,
|
||||
"recursive/unsupported historical descriptor"
|
||||
);
|
||||
let schema = resolve(schema).unwrap();
|
||||
if let Some(options) = alternatives(schema) {
|
||||
let selected = if !active.is_null() && active != "NotApplicable" {
|
||||
options.iter().find(|s| matches_value(s, active, 0))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let selected = selected
|
||||
.or_else(|| {
|
||||
options.iter().find(|s| {
|
||||
!matches_value(s, &Value::Null, 0)
|
||||
&& !matches_value(s, &json!("NotApplicable"), 0)
|
||||
})
|
||||
})
|
||||
.unwrap_or(&options[0]);
|
||||
return witness(selected, active, depth + 1);
|
||||
}
|
||||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||||
return if values.contains(active) {
|
||||
active.clone()
|
||||
} else {
|
||||
values[0].clone()
|
||||
};
|
||||
}
|
||||
match schema["type"].as_str().unwrap() {
|
||||
"object" => schema["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|(key, s)| {
|
||||
(
|
||||
key.clone(),
|
||||
witness(s, active.get(key).unwrap_or(&Value::Null), depth + 1),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>()
|
||||
.into(),
|
||||
"array" => json!([witness(&schema["items"], &Value::Null, depth + 1)]),
|
||||
"string" if schema["format"] == "uuid" => json!(Id::from_u128(777)),
|
||||
"string" => json!("Earlier Ω value\nwith provenance"),
|
||||
"integer" => json!(1),
|
||||
"number" => json!(1.25),
|
||||
"boolean" => json!(true),
|
||||
"null" => Value::Null,
|
||||
other => panic!("unsupported historical schema {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_every_descriptor_fact_choice_matches_actual_closed_types_and_source_contract() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut selectors = BTreeSet::new();
|
||||
let choices = fact_choices().unwrap();
|
||||
assert!(
|
||||
choices.len() > 100,
|
||||
"the full scalar and keyed catalog must be reachable"
|
||||
);
|
||||
for entry in choices {
|
||||
let incomplete: FacetReview = serde_json::from_value(entry.value.clone()).unwrap();
|
||||
assert!(
|
||||
selectors.insert(incomplete.selector()),
|
||||
"duplicate Add selector: {}",
|
||||
entry.label
|
||||
);
|
||||
assert!(
|
||||
semantic::validate_semantic_baseline(&SemanticBaseline {
|
||||
facets: vec![incomplete]
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let complete = witness(named("FacetReview").unwrap(), &entry.value, 0);
|
||||
let typed: FacetReview = serde_json::from_value(complete.clone())
|
||||
.unwrap_or_else(|e| panic!("{}: {e}", entry.label));
|
||||
semantic::validate_semantic_baseline(&SemanticBaseline {
|
||||
facets: vec![typed.clone()],
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("{}: {e:?}", entry.label));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
draft.append(FACETS, complete).unwrap();
|
||||
draft.history.open = true;
|
||||
let _controls = view(&draft, &project, &draft.value["baseline"]);
|
||||
let result = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
let parsed = source_project::compile_bundle(
|
||||
&source_project::format_bundle(&result),
|
||||
Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
parsed.decoded.project, result,
|
||||
"source parity for {}",
|
||||
entry.label
|
||||
);
|
||||
assert_eq!(
|
||||
result.traces[&id]
|
||||
.baseline
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.facets,
|
||||
vec![typed]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_deleted_keys_and_observation_uuids_remain_exact_and_duplicate_history_rejects() {
|
||||
let old_link = Id::from_u128(401);
|
||||
let old_owner = Id::from_u128(402);
|
||||
let fact = FacetReview::Artifact {
|
||||
keys: KeyPair {
|
||||
oa: Some(old_link),
|
||||
sa: None,
|
||||
},
|
||||
review: ArtifactReview::Owner(Reviewed {
|
||||
oa: Observed::Value(old_owner),
|
||||
sa: Observed::NotApplicable,
|
||||
}),
|
||||
};
|
||||
let (project, id) = fixture(Some(baseline(vec![fact])));
|
||||
assert!(!project.contains(old_link) && !project.contains(old_owner));
|
||||
let mut doc = document(&project);
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
let key_path = format!("{FACETS}/0/Artifact/keys/oa");
|
||||
let owner_path = format!("{FACETS}/0/Artifact/review/Owner/oa/Value");
|
||||
assert!(!free_string(&key_path) && !free_string(&owner_path));
|
||||
let edited_key = Id::from_u128(403);
|
||||
let edited_owner = Id::from_u128(404);
|
||||
draft.input(&key_path, edited_key.to_string());
|
||||
draft.input(&owner_path, "bad uuid".into());
|
||||
assert!(draft.command().is_err());
|
||||
draft.input(&owner_path, edited_owner.to_string());
|
||||
let accepted = draft
|
||||
.materialize()
|
||||
.unwrap()
|
||||
.pointer(&format!("{FACETS}/0"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
draft.append(FACETS, accepted.clone()).unwrap();
|
||||
let before = doc.snapshot();
|
||||
assert!(
|
||||
doc.execute(draft.command().unwrap()).is_err(),
|
||||
"overlapping historic addresses must reject"
|
||||
);
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &doc.snapshot()));
|
||||
draft.array(FACETS, Some(1), 0).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.facets = vec![serde_json::from_value(accepted).unwrap()];
|
||||
assert_history_transaction(&mut doc, &draft, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_invalid_numbers_survive_constructor_switch_reorder_removal_and_recovery() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
draft
|
||||
.append(
|
||||
FACETS,
|
||||
choice(FacetSelector::Data(DataFacet::IntegerMinimum)),
|
||||
)
|
||||
.unwrap();
|
||||
let side = format!("{FACETS}/0/Data/IntegerMinimum/oa");
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!({"Value": 7}))
|
||||
.unwrap();
|
||||
draft.input(&format!("{side}/Value"), "-".into());
|
||||
add_header(&mut draft);
|
||||
assert_eq!(draft.inputs[&format!("{side}/Value")].raw, "-");
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
|
||||
.unwrap();
|
||||
draft.array(FACETS, Some(0), 1).unwrap();
|
||||
let moved_side = format!("{FACETS}/1/Data/IntegerMinimum/oa");
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{moved_side}"), json!({"Value": 0}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft.inputs[&format!("{moved_side}/Value")].raw,
|
||||
"-",
|
||||
"inactive constructor drafts follow the historical row"
|
||||
);
|
||||
let saved = draft.recovery_snapshot();
|
||||
let mut recovered = Draft::from_recovery(&project, saved).unwrap();
|
||||
assert!(recovered.history.open);
|
||||
assert_eq!(recovered.inputs[&format!("{moved_side}/Value")].raw, "-");
|
||||
assert!(recovered.command().is_err());
|
||||
recovered.array(FACETS, Some(0), 0).unwrap();
|
||||
assert_eq!(recovered.inputs[&format!("{side}/Value")].raw, "-");
|
||||
recovered.input(&format!("{side}/Value"), i64::MIN.to_string());
|
||||
let mut doc = document(&project);
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(Some(i64::MIN)),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut doc, &recovered, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_compound_value_and_constructor_variants_are_editable_without_live_context() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let old = Id::from_u128(700);
|
||||
let values = vec![
|
||||
FacetReview::Data(DataReview::Default(Reviewed {
|
||||
oa: Observed::Value(Some(DataValue::Boolean(true))),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Field(FieldReview::Type(Reviewed {
|
||||
oa: Observed::Value(TypeRef::Named(old)),
|
||||
sa: Observed::Value(TypeRef::Real),
|
||||
})),
|
||||
FacetReview::Occurrence(OccurrenceReview::Operation(Reviewed {
|
||||
oa: Observed::NotApplicable,
|
||||
sa: Observed::Value(Some(OperationUse {
|
||||
allocation: Some(old),
|
||||
})),
|
||||
})),
|
||||
FacetReview::Relation(RelationReview::ContextSource(Reviewed {
|
||||
oa: Observed::Value(OccurrenceEndpoint {
|
||||
references: vec![old, Id::from_u128(701)],
|
||||
occurrence: Id::from_u128(702),
|
||||
}),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Relation(RelationReview::ContextPurpose(Reviewed {
|
||||
oa: Observed::Value(ContextualLinkPurpose::CommonWork { work: old }),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Annotation {
|
||||
keys: KeyPair {
|
||||
oa: Some(AnnotationKey {
|
||||
path: vec![old],
|
||||
field: old,
|
||||
}),
|
||||
sa: None,
|
||||
},
|
||||
review: Reviewed {
|
||||
oa: Observed::Value(Some(MessageValue {
|
||||
path: vec![old],
|
||||
field: old,
|
||||
expression: "verbatim\tΩ".into(),
|
||||
})),
|
||||
sa: Observed::NotApplicable,
|
||||
},
|
||||
},
|
||||
FacetReview::Binding {
|
||||
keys: KeyPair {
|
||||
oa: Some(old),
|
||||
sa: None,
|
||||
},
|
||||
review: Reviewed {
|
||||
oa: Observed::Value(Some(LifelineBinding {
|
||||
referenced: old,
|
||||
local: Id::from_u128(702),
|
||||
})),
|
||||
sa: Observed::NotApplicable,
|
||||
},
|
||||
},
|
||||
];
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
for value in &values {
|
||||
draft.append(FACETS, json!(value)).unwrap();
|
||||
}
|
||||
let literal = format!("{FACETS}/0/Data/Default/oa/Value");
|
||||
for value in [
|
||||
DataValue::Integer(i64::MAX),
|
||||
DataValue::Real(-0.25),
|
||||
DataValue::String("quoted\r\nΩ".into()),
|
||||
DataValue::Enumeration("Literal".into()),
|
||||
] {
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{literal}"), json!(value))
|
||||
.unwrap();
|
||||
let model = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
matches!(&model.traces[&id].baseline.as_ref().unwrap().semantic.as_ref().unwrap().facets[0], FacetReview::Data(DataReview::Default(Reviewed { oa: Observed::Value(Some(actual)), .. })) if actual == &value)
|
||||
);
|
||||
}
|
||||
let purpose = format!("{FACETS}/4/Relation/ContextPurpose/oa/Value");
|
||||
draft
|
||||
.shape(
|
||||
&format!("{VARIANT}{purpose}"),
|
||||
json!({"Precedence":{"guard":"reviewed before dispatch"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut doc = document(&project);
|
||||
let mut expected = project.clone();
|
||||
expected.traces.get_mut(&id).unwrap().baseline =
|
||||
Some(serde_json::from_value(draft.materialize().unwrap()["baseline"].clone()).unwrap());
|
||||
assert_history_transaction(&mut doc, &draft, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_exact_strings_do_not_confuse_uuid_shaped_prose_or_literal_keys_with_references() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
add_header(&mut draft);
|
||||
let side = format!("{FACETS}/0/Header/Name/oa");
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
|
||||
.unwrap();
|
||||
let path = format!("{side}/Value");
|
||||
assert!(free_string(&path));
|
||||
draft.set_exact(true).unwrap();
|
||||
let value = "00000000-0000-0000-0000-000000000077\tΩ\r\nname";
|
||||
draft.input(&path, serde_json::to_string(value).unwrap());
|
||||
draft
|
||||
.append(
|
||||
FACETS,
|
||||
choice(FacetSelector::Literal(KeyPair { oa: None, sa: None })),
|
||||
)
|
||||
.unwrap();
|
||||
let key = format!("{FACETS}/1/Literal/keys/oa");
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{key}"), json!("literal"))
|
||||
.unwrap();
|
||||
draft.input(&key, serde_json::to_string("literal\tΩ").unwrap());
|
||||
draft
|
||||
.shape(
|
||||
&format!("{VARIANT}{FACETS}/1/Literal/review/oa"),
|
||||
json!({"Value":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(free_string(&key));
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![
|
||||
FacetReview::Header(HeaderReview::Name(Reviewed {
|
||||
oa: Observed::Value(value.into()),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Literal {
|
||||
keys: KeyPair {
|
||||
oa: Some("literal\tΩ".into()),
|
||||
sa: None,
|
||||
},
|
||||
review: Reviewed {
|
||||
oa: Observed::Value(true),
|
||||
sa: Observed::NotApplicable,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_primitive_type_choices_remain_visible_inside_the_tagged_union() {
|
||||
let schema = resolve(named("TypeRef").unwrap()).unwrap();
|
||||
let branches = alternatives(schema).unwrap();
|
||||
let primitives = branches
|
||||
.iter()
|
||||
.find(|branch| {
|
||||
resolve(branch)
|
||||
.unwrap()
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|values| values.len() > 1)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
branch_has_fields(primitives),
|
||||
"the primitive branch needs an inner picker, not only its outer union label"
|
||||
);
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
draft
|
||||
.append(
|
||||
FACETS,
|
||||
json!(FacetReview::Field(FieldReview::Type(Reviewed {
|
||||
oa: Observed::Value(TypeRef::Named(Id::from_u128(888))),
|
||||
sa: Observed::NotApplicable,
|
||||
}))),
|
||||
)
|
||||
.unwrap();
|
||||
let path = format!("{FACETS}/0/Field/Type/oa/Value");
|
||||
draft
|
||||
.shape(
|
||||
&format!("{VARIANT}{path}"),
|
||||
branch_seed(primitives).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
for value in [
|
||||
TypeRef::Boolean,
|
||||
TypeRef::Integer,
|
||||
TypeRef::Real,
|
||||
TypeRef::String,
|
||||
] {
|
||||
assert!(matches_value(primitives, &json!(value), 0));
|
||||
draft.shape(&path, json!(value)).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Field(FieldReview::Type(Reviewed {
|
||||
oa: Observed::Value(value),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_retained_text_constructors_follow_exact_mode_without_changing_the_observation() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
add_header(&mut draft);
|
||||
let side = format!("{FACETS}/0/Header/Name/oa");
|
||||
let path = format!("{side}/Value");
|
||||
let stored = "Earlier \"quoted\" name\tΩ\r\nsecond line";
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
|
||||
.unwrap();
|
||||
draft.input(&path, stored.into());
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
|
||||
.unwrap();
|
||||
draft.set_exact(true).unwrap();
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft.inputs[&path].raw,
|
||||
serde_json::to_string(stored).unwrap()
|
||||
);
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
|
||||
.unwrap();
|
||||
draft.set_exact(false).unwrap();
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
|
||||
.unwrap();
|
||||
assert_eq!(draft.inputs[&path].raw, stored);
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Header(HeaderReview::Name(Reviewed {
|
||||
oa: Observed::Value(stored.into()),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_clear_discards_inactive_invalid_constructors_before_reusing_the_same_row() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
add_header(&mut draft);
|
||||
let side = format!("{FACETS}/0/Header/Name/oa");
|
||||
let path = format!("{side}/Value");
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
|
||||
.unwrap();
|
||||
draft.set_exact(true).unwrap();
|
||||
draft.input(&path, "\"unfinished".into());
|
||||
draft
|
||||
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
draft.set_exact(false).is_err(),
|
||||
"invalid retained quoted text cannot silently change modes"
|
||||
);
|
||||
assert!(draft.exact_strings);
|
||||
draft.shape(ROOT, Value::Null).unwrap();
|
||||
assert!(draft.constructor_drafts.is_empty());
|
||||
draft.set_exact(false).unwrap();
|
||||
draft.shape(ROOT, json!(baseline(vec![]))).unwrap();
|
||||
add_header(&mut draft);
|
||||
draft
|
||||
.shape(
|
||||
&format!("{VARIANT}{side}"),
|
||||
json!({"Value":"Fresh observation"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(draft.inputs[&path].raw, "Fresh observation");
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Header(HeaderReview::Name(Reviewed {
|
||||
oa: Observed::Value("Fresh observation".into()),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
}
|
||||
@@ -655,7 +655,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Review drift deliberately",
|
||||
"On an existing trace, choose Review counterpart changes… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. A manual trace records correspondence without propagating properties.",
|
||||
"On an existing trace, choose Review counterpart changes… to compare typed content and prior observations. Choose Keep for the destination, Take for the source, or Set final value / Set final kind and payload where offered. Review mapped references and proposed changes, then choose Apply reviewed changes. Independent edits may be intentional; keeping a value records that decision. Manual traces record engineer-defined correspondence. Ordinary edits never synchronize counterparts automatically; an explicit supported transition or review can update selected properties, including for a Manual trace.",
|
||||
),
|
||||
explain(
|
||||
"Know what reconciliation changes",
|
||||
|
||||
Reference in New Issue
Block a user