Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
555f2b9d6d | ||
|
|
563772dc62 | ||
|
|
17d56c06f7 | ||
|
|
36eff7225a | ||
|
|
b4c8fd7102 | ||
|
|
d8bdbd9e48 | ||
|
|
1e9f0c2a97 | ||
|
|
b6b76efc0d | ||
|
|
01bb52a76d |
@@ -1101,11 +1101,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);
|
||||
}
|
||||
@@ -1363,6 +1365,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,
|
||||
@@ -1381,6 +1395,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)
|
||||
@@ -1406,6 +1459,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,
|
||||
|
||||
@@ -343,7 +343,7 @@ impl App {
|
||||
Event::Name(value) => {
|
||||
if value.len() <= 16384
|
||||
&& let Some(s) = &mut self.architecture
|
||||
&& let (Intent::Create { name, .. } | Intent::Rename { name, .. }) =
|
||||
&& let Intent::Create { name, .. } | Intent::Rename { name, .. } =
|
||||
&mut s.saved.intent
|
||||
{
|
||||
*name = value;
|
||||
@@ -422,7 +422,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
Event::Click(context, point) => {
|
||||
if self.route_context() != Some(context) {
|
||||
if self.route_context() != Some(context)
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.architecture.as_ref().is_some_and(|s| s.saved.placing) {
|
||||
@@ -460,6 +463,10 @@ impl App {
|
||||
self.status =
|
||||
"The diagram changed. The name is retained; cancel and restart this placement."
|
||||
.into();
|
||||
if let Some(s) = &mut self.architecture {
|
||||
s.saved.placing = false;
|
||||
s.error = Some(self.status.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
let diagram = s.saved.diagram;
|
||||
|
||||
@@ -4,7 +4,7 @@ use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
canvas::{self, Frame, Geometry, Path, Stroke},
|
||||
container, tooltip,
|
||||
column, container, tooltip,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -621,8 +621,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
Key::Named(Named::F2)
|
||||
if self.editable && self.connection.is_none() && modifiers.is_empty() =>
|
||||
{
|
||||
let view = self.scene(state).view;
|
||||
let node = view
|
||||
let scene = self.scene(state);
|
||||
let node = scene
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == self.selected || n.id == self.selected)?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -384,6 +449,9 @@ impl Draft {
|
||||
None
|
||||
};
|
||||
let tag = |value: &Value| {
|
||||
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
|
||||
return history_fields::constructor_tag(path, value);
|
||||
}
|
||||
value
|
||||
.as_str()
|
||||
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
|
||||
@@ -448,7 +516,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 +542,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 +595,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 +605,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 +716,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 +901,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 +1825,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,705 @@
|
||||
//! 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)
|
||||
}
|
||||
|
||||
/// Constructor identity is independent of a primitive's edited content. In
|
||||
/// particular, absent, an empty UUID/text, a number and a list are different
|
||||
/// branches. String enum tags and externally tagged objects retain their variant.
|
||||
pub(super) fn constructor_tag(path: &str, value: &Value) -> String {
|
||||
fn enum_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(|option| enum_value(option, value, depth + 1));
|
||||
}
|
||||
schema
|
||||
.get("enum")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|values| values.contains(value))
|
||||
}
|
||||
match value {
|
||||
Value::Null => "null".into(),
|
||||
Value::Bool(_) => "boolean".into(),
|
||||
Value::Number(_) => "number".into(),
|
||||
Value::Array(_) => "array".into(),
|
||||
Value::String(word)
|
||||
if schema_at(path).is_some_and(|schema| enum_value(schema, value, 0)) =>
|
||||
{
|
||||
format!("enum:{word}")
|
||||
}
|
||||
Value::String(_) => "string".into(),
|
||||
Value::Object(fields) => format!(
|
||||
"object:{}",
|
||||
fields.keys().cloned().collect::<Vec<_>>().join("/")
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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,911 @@
|
||||
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);
|
||||
}
|
||||
|
||||
/// Exercise the same descriptor choice and FieldShape event emitted by the
|
||||
/// nested picker. Injecting an already complete Observed value skips this path.
|
||||
fn choose_history_constructor(draft: &mut Draft, path: &str, label: &str) {
|
||||
let schema = resolve(schema_at(path).expect("typed history field")).unwrap();
|
||||
let branch = alternatives(schema)
|
||||
.expect("constructor choices")
|
||||
.iter()
|
||||
.find(|branch| branch_label(branch) == label)
|
||||
.expect("visible constructor choice");
|
||||
let event = variant_message(
|
||||
path,
|
||||
branch_seed(branch).unwrap(),
|
||||
draft.value.pointer(path).is_some(),
|
||||
);
|
||||
let Message::FieldShape(path, value) = event else {
|
||||
panic!("constructor picker must emit FieldShape");
|
||||
};
|
||||
draft.shape(&path, value).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_nested_optional_number_events_switch_absent_present_and_recover_invalid_raw() {
|
||||
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");
|
||||
let path = format!("{side}/Value");
|
||||
choose_history_constructor(&mut draft, &side, "Observed value");
|
||||
assert_eq!(draft.value.pointer(&path), Some(&Value::Null));
|
||||
choose_history_constructor(&mut draft, &path, "Set number");
|
||||
assert_eq!(
|
||||
draft.value.pointer(&path),
|
||||
Some(&json!(0)),
|
||||
"null and numeric constructor identities must differ"
|
||||
);
|
||||
draft.input(&path, "-".into());
|
||||
choose_history_constructor(&mut draft, &path, "Absent (none)");
|
||||
assert_eq!(draft.value.pointer(&path), Some(&Value::Null));
|
||||
let mut draft = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
choose_history_constructor(&mut draft, &path, "Set number");
|
||||
assert_eq!(draft.inputs[&path].raw, "-");
|
||||
assert!(draft.command().is_err());
|
||||
draft.input(&path, "7".into());
|
||||
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(7)),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
choose_history_constructor(&mut draft, &path, "Absent (none)");
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
|
||||
oa: Observed::Value(None),
|
||||
sa: Observed::NotApplicable,
|
||||
}))],
|
||||
});
|
||||
assert_history_transaction(&mut document(&project), &draft, &expected);
|
||||
choose_history_constructor(&mut draft, &path, "Set number");
|
||||
assert_eq!(
|
||||
draft.inputs[&path].raw, "7",
|
||||
"returning to Value restores its edited contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_nested_uuid_and_list_events_preserve_present_values_and_tagged_item_branches() {
|
||||
let (project, id) = fixture(Some(baseline(vec![])));
|
||||
let mut draft = Draft::from_project(&project, id).unwrap();
|
||||
draft
|
||||
.append(FACETS, choice(FacetSelector::Header(HeaderFacet::Parent)))
|
||||
.unwrap();
|
||||
draft
|
||||
.append(FACETS, choice(FacetSelector::Field(FieldFacet::Default)))
|
||||
.unwrap();
|
||||
let side = format!("{FACETS}/0/Header/Parent/oa");
|
||||
let uuid = format!("{side}/Value");
|
||||
choose_history_constructor(&mut draft, &side, "Observed value");
|
||||
choose_history_constructor(&mut draft, &uuid, "Set historical UUID");
|
||||
assert_eq!(
|
||||
draft.value.pointer(&uuid),
|
||||
Some(&json!("")),
|
||||
"empty UUID input is distinct from absent"
|
||||
);
|
||||
assert!(draft.command().is_err());
|
||||
draft.input(&uuid, "incomplete-uuid".into());
|
||||
choose_history_constructor(&mut draft, &uuid, "Absent (none)");
|
||||
choose_history_constructor(&mut draft, &uuid, "Set historical UUID");
|
||||
assert_eq!(draft.inputs[&uuid].raw, "incomplete-uuid");
|
||||
let historical = Id::from_u128(8765);
|
||||
draft.input(&uuid, historical.to_string());
|
||||
choose_history_constructor(&mut draft, &uuid, "Absent (none)");
|
||||
choose_history_constructor(&mut draft, &uuid, "Set historical UUID");
|
||||
assert_eq!(draft.inputs[&uuid].raw, historical.to_string());
|
||||
|
||||
let side = format!("{FACETS}/1/Field/Default/oa");
|
||||
let list = format!("{side}/Value");
|
||||
choose_history_constructor(&mut draft, &side, "Observed value");
|
||||
choose_history_constructor(&mut draft, &list, "Set list");
|
||||
assert_eq!(
|
||||
draft.value.pointer(&list),
|
||||
Some(&json!([])),
|
||||
"empty list is distinct from absent"
|
||||
);
|
||||
let array_schema = alternatives(resolve(schema_at(&list).unwrap()).unwrap())
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|branch| resolve(branch).unwrap())
|
||||
.find(|branch| branch["type"] == "array")
|
||||
.unwrap();
|
||||
let event = Message::FieldAppend(list.clone(), seed(&array_schema["items"], 0).unwrap());
|
||||
let Message::FieldAppend(path, value) = event else {
|
||||
unreachable!()
|
||||
};
|
||||
draft.append(&path, value).unwrap();
|
||||
let item = format!("{list}/0");
|
||||
choose_history_constructor(&mut draft, &item, "Integer");
|
||||
draft.input(&format!("{item}/Integer"), "-".into());
|
||||
choose_history_constructor(&mut draft, &item, "String");
|
||||
draft.input(&format!("{item}/String"), "retained textual branch".into());
|
||||
choose_history_constructor(&mut draft, &item, "Integer");
|
||||
assert_eq!(draft.inputs[&format!("{item}/Integer")].raw, "-");
|
||||
draft.input(&format!("{item}/Integer"), "21".into());
|
||||
choose_history_constructor(&mut draft, &list, "Absent (none)");
|
||||
assert_eq!(draft.value.pointer(&list), Some(&Value::Null));
|
||||
let mut draft = Draft::from_recovery(&project, draft.recovery_snapshot()).unwrap();
|
||||
choose_history_constructor(&mut draft, &list, "Set list");
|
||||
assert_eq!(draft.inputs[&format!("{item}/Integer")].raw, "21");
|
||||
choose_history_constructor(&mut draft, &item, "String");
|
||||
assert_eq!(
|
||||
draft.inputs[&format!("{item}/String")].raw,
|
||||
"retained textual branch"
|
||||
);
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.semantic = Some(SemanticBaseline {
|
||||
facets: vec![
|
||||
FacetReview::Header(HeaderReview::Parent(Reviewed {
|
||||
oa: Observed::Value(Some(historical)),
|
||||
sa: Observed::NotApplicable,
|
||||
})),
|
||||
FacetReview::Field(FieldReview::Default(Reviewed {
|
||||
oa: Observed::Value(Some(vec![DataValue::String(
|
||||
"retained textual branch".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",
|
||||
|
||||
+21
-11
@@ -667,7 +667,7 @@ Actual verification evidence is separate from this contract.</p>
|
||||
</section>
|
||||
<section id="one-authoritative-model">
|
||||
<h2>2. One authoritative model</h2>
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (6), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (7), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
|
||||
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
|
||||
@@ -730,6 +730,10 @@ lists all additional fields. <span class="docutils literal">ref</span> means <sp
|
||||
<td><p><span class="docutils literal">kind</span>: Information, Material, Energy; <span class="docutils literal">mechanism</span>: ExchangeMechanism; <span class="docutils literal">supertype</span>: item ref?</p></td>
|
||||
<td><p>Information; Unspecified; none</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_exchange_category</span>, <span class="docutils literal">sa_exchange_category</span></p></td>
|
||||
<td><p><span class="docutils literal">family</span>: Work or Participant; <span class="docutils literal">exchanges</span>: same-layer exchange refs</p></td>
|
||||
<td><p>Work; <span class="docutils literal">[]</span>; root-level only</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_data_type</span>, <span class="docutils literal">sa_data_type</span></p></td>
|
||||
<td><p><span class="docutils literal">definition</span>: DataTypeDefinition; <span class="docutils literal">default</span>: DataValue?; <span class="docutils literal">unit</span>: string</p></td>
|
||||
<td><p>String with minimum_length 0 / maximum_length none; none; <span class="docutils literal">""</span></p></td>
|
||||
@@ -869,7 +873,9 @@ work and produces a warning. The System cannot own internal components.
|
||||
The present Studio profile does not decompose SA Actors.</p>
|
||||
<p>All relations have a header identity, alias, display name, optional
|
||||
<span class="docutils literal">description</span> (default <span class="docutils literal">""</span>), and required <span class="docutils literal">source</span> and <span class="docutils literal">target</span>
|
||||
references. Their kind is the declaration keyword:</p>
|
||||
endpoints. Ordinary endpoints are semantic references; ContextualLink endpoints
|
||||
are qualified OccurrenceEndpoint records resolved from its required <span class="docutils literal">owner</span>.
|
||||
Their kind is the declaration keyword:</p>
|
||||
<table>
|
||||
<caption>Relation catalog</caption>
|
||||
<colgroup>
|
||||
@@ -956,6 +962,10 @@ references. Their kind is the declaration keyword:</p>
|
||||
<td><p>Preceding contribution → following contribution in one process/chain</p></td>
|
||||
<td><p><span class="docutils literal">guard</span> string / ""</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">contextual_link</span></p></td>
|
||||
<td><p>Qualified occurrence endpoints resolved from an explicit owner Process/FunctionalChain; section 4.2.1</p></td>
|
||||
<td><p>Required <span class="docutils literal">owner</span>, <span class="docutils literal">source</span> and <span class="docutils literal">target</span> OccurrenceEndpoint records, and typed <span class="docutils literal">purpose</span>: Precedence, Exchange or CommonWork</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">state_transition</span></p></td>
|
||||
<td><p>State vertices in one machine, subject to section 4.4</p></td>
|
||||
<td><p><span class="docutils literal">trigger</span>, <span class="docutils literal">guard</span>, <span class="docutils literal">effect</span> strings / ""; <span class="docutils literal">transition_kind</span> External; <span class="docutils literal">triggers</span>, <span class="docutils literal">effects</span> reference lists / []</p></td>
|
||||
@@ -1811,7 +1821,7 @@ are invalid.</p>
|
||||
<td><p>Operational Mission → System Mission</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Exchange</p></td>
|
||||
<td><p>Interaction relation → FunctionalExchange relation</p></td>
|
||||
<td><p>Interaction → FunctionalExchange, or CommunicationMean → BoundaryExchange</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Sequence</p></td>
|
||||
<td><p>Process → FunctionalChain, or paired occurrence contributions directly owned by Process / FunctionalChain</p></td>
|
||||
@@ -2261,7 +2271,7 @@ deeply nested drafts produce a diagnostic instead of exhausting the parser stack
|
||||
<h3>6.1. Modular source projects</h3>
|
||||
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
|
||||
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source5 project header above. A project entry
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source7 project header above. A project entry
|
||||
MAY also be <span class="docutils literal">include string ";"</span>. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
@@ -2324,15 +2334,15 @@ the source index. Columns remain one-based Unicode scalars within the real file;
|
||||
spans never refer to concatenated or synthetic source.</p>
|
||||
<p>Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3, source4 and
|
||||
source5. Source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
continue through explicit migration. Modular projects accept source3 through
|
||||
source7. Older bundles are upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
An authored source4 bundle upgrades through the strict prose-only baseline
|
||||
adapter; after any missing UUID stabilization, the version token alone changes
|
||||
to 5. Existing prose, comments, includes and nested ownership remain intact.
|
||||
to 7. Existing prose, comments, includes and nested ownership remain intact.
|
||||
Source5 semantic facet comments follow their closed selector identity through
|
||||
row reordering and reidentification; UUID-looking text does not act as an Id.</p>
|
||||
</section>
|
||||
@@ -2654,7 +2664,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3, schema-4 and schema-5 migrations.</p>
|
||||
the added defaults through explicit migration to the current schema 7.</p>
|
||||
<p>The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||
@@ -2671,13 +2681,13 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 5. Model/source exports contain only the canonical model. Migration
|
||||
schema 7. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.</p>
|
||||
<p>Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V5 graph are
|
||||
construction. Both the old V2 contract and the resulting current graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4-to-5 stages.</p>
|
||||
model and then uses the same explicit adapters to reach schema 7.</p>
|
||||
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
not truncate a previous file. External concurrent-writer conflict resolution
|
||||
|
||||
+10
-2
@@ -103,6 +103,9 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
|
||||
* - ``oa_exchange_item``, ``sa_exchange_item``
|
||||
- ``kind``: Information, Material, Energy; ``mechanism``: ExchangeMechanism; ``supertype``: item ref?
|
||||
- Information; Unspecified; none
|
||||
* - ``oa_exchange_category``, ``sa_exchange_category``
|
||||
- ``family``: Work or Participant; ``exchanges``: same-layer exchange refs
|
||||
- Work; ``[]``; root-level only
|
||||
* - ``oa_data_type``, ``sa_data_type``
|
||||
- ``definition``: DataTypeDefinition; ``default``: DataValue?; ``unit``: string
|
||||
- String with minimum_length 0 / maximum_length none; none; ``""``
|
||||
@@ -223,7 +226,9 @@ The present Studio profile does not decompose SA Actors.
|
||||
|
||||
All relations have a header identity, alias, display name, optional
|
||||
``description`` (default ``""``), and required ``source`` and ``target``
|
||||
references. Their kind is the declaration keyword:
|
||||
endpoints. Ordinary endpoints are semantic references; ContextualLink endpoints
|
||||
are qualified OccurrenceEndpoint records resolved from its required ``owner``.
|
||||
Their kind is the declaration keyword:
|
||||
|
||||
.. list-table:: Relation catalog
|
||||
:header-rows: 1
|
||||
@@ -286,6 +291,9 @@ references. Their kind is the declaration keyword:
|
||||
* - ``sequence_link``
|
||||
- Preceding contribution → following contribution in one process/chain
|
||||
- ``guard`` string / ""
|
||||
* - ``contextual_link``
|
||||
- Qualified occurrence endpoints resolved from an explicit owner Process/FunctionalChain; section 4.2.1
|
||||
- Required ``owner``, ``source`` and ``target`` OccurrenceEndpoint records, and typed ``purpose``: Precedence, Exchange or CommonWork
|
||||
* - ``state_transition``
|
||||
- State vertices in one machine, subject to section 4.4
|
||||
- ``trigger``, ``guard``, ``effect`` strings / ""; ``transition_kind`` External; ``triggers``, ``effects`` reference lists / []
|
||||
@@ -1190,7 +1198,7 @@ are invalid.
|
||||
* - Mission
|
||||
- Operational Mission → System Mission
|
||||
* - Exchange
|
||||
- Interaction relation → FunctionalExchange relation
|
||||
- Interaction → FunctionalExchange, or CommunicationMean → BoundaryExchange
|
||||
* - Sequence
|
||||
- Process → FunctionalChain, or paired occurrence contributions directly owned by Process / FunctionalChain
|
||||
* - Item
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ Arcadia Studio implements a systems-engineering workbench spanning Operational A
|
||||
| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints |
|
||||
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed descriptive and typed semantic changes, explicit ordered collections and combined multi-source review, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
|
||||
| Views and editing | Independent and duplicated diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/communication/functional/boundary exchange tools, named exchange categories, saved line-kind/category/coverage/context/hide/label settings, contextual menus, SVG export, model explorer and typed properties |
|
||||
| Text and storage | `arcadia 6` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, per-file atomic replacement with interrupted source-save recovery, Home/recents and private unsaved-work checkpoints |
|
||||
| Text and storage | `arcadia 7` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, per-file atomic replacement with interrupted source-save recovery, Home/recents and private unsaved-work checkpoints |
|
||||
| Automation and hosted editing | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows; optional desktop catalog/open/publish, explicit conditional Save, server review and local Save as; ETag preconditions, bounded requests and atomic current-revision storage |
|
||||
|
||||
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
|
||||
|
||||
@@ -211,6 +211,57 @@ Decision: retain same-layer admission for ordinary diagrams, but allow both-laye
|
||||
|
||||
Alternatives rejected: filtering every view by its labelled layer loses existing Traceability content; ignoring layer validation everywhere hides mistaken references; serializing temporary GUI filters breaks source/API parity. Revisit if native workflows or cross-layer regression evidence exposes ambiguity. Current implementation is pending integrated tests and native acceptance, not an established release result.
|
||||
|
||||
## Continuous behavior authoring — 2026-09-10
|
||||
|
||||
Problem: adding the first Exchange to an empty process changed its interactive
|
||||
Graph into an ordered sequence Scene without gesture tools. Scenario non-message
|
||||
creation discarded the visible operand and insertion point, defaulting to root
|
||||
append. These are verified source paths; new-binary native acceptance is pending.
|
||||
|
||||
Select one interactive process renderer. Project legacy ordered adjacency as
|
||||
endpoint pairs without fabricated semantic relation IDs. Loading and selecting
|
||||
remain read-only. The first topology mutation uses the existing validated batch
|
||||
to materialize unaffected adjacency and splice the chosen insertion atomically.
|
||||
Reject the alternative of a special 0/1-element renderer exception, which would
|
||||
reintroduce the discontinuity at a later element count; also reject automatic
|
||||
load-time migration and artificial self-links. Core legacy/explicit semantics
|
||||
and the source version are unchanged. Revisit if actual imported-path editing
|
||||
cannot preserve ordering, rejection and exact source/Undo behavior.
|
||||
|
||||
Scenario Action, State, Reuse and empty Fragment creation capture the clicked
|
||||
owner, operand, before-occurrence and optional lifeline before starting the
|
||||
existing typed draft. Keep current validation, recovery and atomic history.
|
||||
Fragment range wrapping and cross-operand movement require separate explicit
|
||||
transactions. Do not infer persisted lifeline geometry from a click: the current
|
||||
model does not store horizontal lifeline order. Reused-scenario navigation uses
|
||||
bounded transient caller/use context, never an extra model history entry.
|
||||
|
||||
Evidence and acceptance: `release-behavior-audit-20260910.md`, Gitea #1, new
|
||||
production-pointer regression paths and independent source reviews. Compilation,
|
||||
automated execution and native validation are still pending at this decision.
|
||||
|
||||
## Typed stored observations — 2026-09-10
|
||||
|
||||
Problem: generic trace-baseline Add inserted a UUID string into the closed
|
||||
FacetReview list, producing an invalid draft. Hiding Add and routing only to
|
||||
current review/reset cannot fulfill the normative contract: historical values
|
||||
and references may be valid even when their live definitions no longer exist.
|
||||
|
||||
Select an explicit advanced typed history editor over the existing OpenAPI
|
||||
TraceBaseline/FacetReview descriptors. They provide the finite shape catalog
|
||||
already required for REST parity; final authority remains core typed decoding,
|
||||
historical admission and one EditTrace through Document. Preserve invalid raw
|
||||
drafts, exact historical UUID text, optional/Observed alternatives and explicit
|
||||
constructor selection. Current observations may be copied only by a deliberate
|
||||
action. Cache the descriptor and bound recursive rendering; unsupported shapes
|
||||
must fail visibly instead of generating an invalid default.
|
||||
|
||||
Reject a separate handwritten GUI metamodel or a new duplicate core type-shape
|
||||
catalog, and reject raw JSON as the sole graphical editing surface. Revisit if
|
||||
descriptor/core consistency or native usability cannot be verified. This choice
|
||||
is under implementation; it does not yet close the parity finding in
|
||||
`release-end-state-audit-20260910.md`.
|
||||
|
||||
## Basic canvas authoring — 2026-09-07
|
||||
|
||||
The user rejected the large horizontal creation row. Actual installed Capella 7.1
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# First release execution plan
|
||||
|
||||
## Active execution — 2026-09-10
|
||||
|
||||
1. Restore a reproducible local toolchain; repair the interrupted compact palette;
|
||||
verify focused history/draft/placement regressions and the actual native workflow.
|
||||
2. Independently reassess current behavior/scenario ergonomics and OA/SA semantic,
|
||||
source/API and documentation completeness. Resolve verified high-impact gaps
|
||||
with bounded changes and cross-representation acceptance criteria.
|
||||
3. Complete realistic graphical OA/SA teaching, transition, behavior, recovery and
|
||||
hosted workflows. Exercise minimum and wide layouts, rejected edits and history.
|
||||
4. Run current Windows/Linux automated and native acceptance, fresh independent
|
||||
end-state reviews, optimized builds and package verification. Publish accurate
|
||||
build/run evidence and handoff only when the original release criteria hold.
|
||||
|
||||
This ordering supersedes historical stopped-session next actions below. Preserve
|
||||
their scoped evidence; no test or runtime result transfers to a new binary without
|
||||
verification. Root holds the serialized build/native lease; Gitea #1 tracks workers.
|
||||
|
||||
**Latest refinement:** consult actual installed Capella 7.1, replace the user-rejected horizontal creation/connection rows with a compact contextual palette and inline authoring, independently critique interaction risks, then build and verify the real workflow at wide/minimum sizes. [Benchmark and concrete acceptance](release-capella-basic-editing-20260907.md). Do not ship the rejected toolbar as the usability correction.
|
||||
|
||||
**Current priority (7 September):** the user's usability correction takes precedence over the advanced feature sequence below. Establish contextual named diagram creation, existing-element placement, basic editing and clear layer transitions, then verify them natively. Desktop checks were explicitly reauthorized. See [priority reset and acceptance](release-basic-editing-reset-20260907.md).
|
||||
|
||||
@@ -1,5 +1,98 @@
|
||||
# Progress Ledger — first release
|
||||
|
||||
## Active continuation — 2026-09-10
|
||||
|
||||
The renewed user request is preserved in `PROJECT_GOAL.md`. Root is integrating
|
||||
the first release, prioritizing direct diagram authoring and behavior/scenario
|
||||
usability. The checkout began clean at `1797516` on `codex/release1-continuation`.
|
||||
Gitea issue #1 and Wiki Home were read; ownership and progress are coordinated there.
|
||||
|
||||
- Root owns integration, serialized builds, native Computer Use and these ledgers.
|
||||
`palette_repair` owns architecture authoring and `diagram.rs`; `behavior_audit`
|
||||
and `release_audit` independently inspect ergonomics and release coverage.
|
||||
- The recorded palette compilation errors have local corrections pending a fresh
|
||||
build. Earlier 584-test evidence does not cover those corrections.
|
||||
- This checkout began without the historical `.local` toolchain/cache/packages.
|
||||
Project-local Rust/Cargo 1.98.1 GNU/LLVM and verified LLVM-MinGW 20260908
|
||||
(clang 23.1.1) are installed. Locked dependencies are being fetched.
|
||||
Host: four logical processors, 8 GiB RAM; builds remain serialized.
|
||||
- Computer Use initialized successfully. The renewed request authorizes native
|
||||
evaluation; old session Escape holds and process IDs are historical. No Arcadia
|
||||
or Capella window is currently running.
|
||||
- WSL lists no installed distribution. Linux runtime/build acceptance remains an
|
||||
open gate; no historical denied guest workaround is being retried.
|
||||
|
||||
**Current branch is `codex/behavior-authoring-20260910`.** A distinct Gitea user
|
||||
`Codex-9e8r6t` is running another root/team on a separate environment with the old
|
||||
build cache. Our identity is **Codex-956atf**. Gitea #25 and #38 acknowledge the
|
||||
scope split: their palette/platform/package and subsequent scenario layout work;
|
||||
our process/scenario adapters and typed-history work. We will publish a clearly
|
||||
untested coherent snapshot on this distinct branch for their warm-cache checks.
|
||||
No shared branch-tip push or merge has occurred here.
|
||||
Their reports/results must not be claimed as local validation or overwritten.
|
||||
|
||||
Implemented locally, awaiting compilation:
|
||||
|
||||
- Palette compile repair, retained inline name after rejected stale Submit,
|
||||
nonfinite click rejection and rename/allocation/history regressions.
|
||||
- One interactive process renderer with presentation-only inferred ordered
|
||||
paths; first editing batch preserves adjacency atomically. Independent source
|
||||
review found no confirmed defect. Root added 3+ step/before-first insertion
|
||||
and incompatible imported conversion tests after the reviewer hit model capacity.
|
||||
- Scenario Action/State/Reuse/empty Fragment tools capture clicked operand/slot
|
||||
and optional lifeline. Pointer-to-App, recovery, stale-event and history tests
|
||||
are written. A bounded exact caller/use return path and scoped mounted-scroll
|
||||
restoration are implemented with regressions, also untested.
|
||||
- Advanced typed stored-history editor uses existing OpenAPI type descriptors;
|
||||
twelve focused tests cover typed selectors, historical IDs, invalid raw input,
|
||||
recovery and source/history. Core validation remains authoritative.
|
||||
- Normative catalogs now include ExchangeCategory, ContextualLink and both
|
||||
Exchange trace pairs. Manual trace help and scope source version corrected;
|
||||
strict Docutils 0.22.2 HTML regeneration passed.
|
||||
|
||||
Reference evidence: actual Capella 7.1 scenario/lifeline/LOOP creation and saved
|
||||
XML are recorded in `release-capella-scenario-benchmark-20260910.md`; the isolated
|
||||
Capella process closed normally and is absent. This is not Studio acceptance.
|
||||
|
||||
Toolchain bootstrap exec session **20812** completed successfully: verified
|
||||
LLVM-MinGW 20260908 ZIP SHA256
|
||||
`1bcf74d06b724aeecaa6412ca85f5b26fb1da770e7cdcefa9263c9c5c3ad34b6`, extracted
|
||||
successfully; stable **1.98.1** GNU/LLVM, rustfmt and Clippy installed.
|
||||
`.local/toolchain/env.ps1` configures task-local Cargo/Rustup/PATH and one build
|
||||
job. Locked dependency fetch is local exec session **13108**. No build/test pass
|
||||
is established; the target cache is still cold.
|
||||
|
||||
The coherent candidate `01bb52a` passed `cargo fmt --all` and `git diff --check`.
|
||||
Direct push failed because this fresh host has no Git credentials. Authenticated
|
||||
Gitea MCP transferred its exact verified Git bundle through
|
||||
`codex/behavior-transfer-20260910` (`harness/transfers/behavior-01bb52a.bundle.b64`),
|
||||
prerequisite `1797516`, decoded SHA256
|
||||
`85A38B00207036DCB8DB24ED4A7E80B2BE670275EEB7B00413D36153322ADA5D`.
|
||||
This **UNTESTED integration snapshot** lets the other host use its warm cache;
|
||||
transfer is not acceptance. Their broader architecture
|
||||
palette/Escape repair should be reconciled with our overlapping compile/stale
|
||||
repairs while retaining the additional invariant tests. Their measured minimum
|
||||
scenario canvas is too small; they own the subsequent layout implementation.
|
||||
Their isolated palette commit is `7d1df4f`, fetched locally for reconciliation.
|
||||
|
||||
Independent F1 review found a real optional-value cache collision in `01bb52a`.
|
||||
The follow-up distinguishes null/scalar/list/tagged constructor identities and
|
||||
adds actual inner selector-event tests. Independent source recheck found no
|
||||
remaining collision; execution is still pending. The original full-platform
|
||||
fetch was stopped after network timeouts; current Windows desktop test build
|
||||
is exec session **51036**, compiling dependencies. Root owns this Cargo lease.
|
||||
|
||||
Next: focused `architecture_authoring_tests`,
|
||||
`behavior::topology`, `direct_clicked`, caller-navigation and `history_fields::tests`,
|
||||
then full relevant gates and a fresh native binary. Root owns all Cargo on this
|
||||
host. Worker output or tests existing in source are not passes.
|
||||
|
||||
Gitea Actions run16 is queued for both ubuntu-latest and windows-latest with no
|
||||
started steps, as are older listed runs. No usable current Linux runner result
|
||||
is established and no duplicate dispatch was made. Progress is concrete;
|
||||
release acceptance remains incomplete. Older checkpoint paragraphs below retain
|
||||
their original revision scope.
|
||||
|
||||
**Checkpoint publication — 2026-09-10:** the user requested committing all current changes and pushing `codex/release1-continuation` to both `origin` and `gitea`. This is a work-in-progress snapshot. The interrupted palette-focused compilation failed with E0659 (ambiguous `column!` in `app/architecture_authoring/palette.rs:168`) and E0507 (moving the view out of an Arc in `diagram.rs:624`); an unnecessary-pattern-parentheses warning also remains in `architecture_authoring.rs:346`. The prior 584-test pass does not cover this newer palette revision. No Cargo/rustc process or local subagent was active at the publication preflight. Fix these errors and complete automated/native acceptance before packaging the redesign. Gitea coordination starts at issue #1 and the project Wiki; older evidence below retains its original scope.
|
||||
|
||||
**Latest priority checkpoint — Capella consultation and redesign:** the user rejected the large creation buttons and explicitly requested consulting the installed Capella. Native control resumed for that consultation in a disposable Capella 7.1 workspace. Actual palette, F2 rename and activity allocation were exercised; saved XML confirms allocation distinct from ownership. The horizontal creation/connection rows are being replaced with a compact expandable palette and inline naming. This supersedes the Escape hold and toolbar-fix proposal below. New implementation, native acceptance and a fresh user build remain pending. [Observed benchmark, alternatives and acceptance criteria](release-capella-basic-editing-20260907.md). The first-release goal remains active.
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# Task Ledger — first release
|
||||
|
||||
## Current knowledge — 2026-09-10
|
||||
|
||||
Given: the renewed objective in `PROJECT_GOAL.md` requires a feature-complete,
|
||||
usable OA/SA first release and prioritizes direct behavior/scenario authoring.
|
||||
Graphical/source/REST parity, explicit reST semantics, traceability, offline
|
||||
guidance, Windows/Linux acceptance and independent review remain required.
|
||||
Older scope deferrals must be checked against this request.
|
||||
|
||||
Verified: the checkout began clean at `1797516`; Gitea contains one active
|
||||
continuation issue and no open PR. Current code includes direct behavior editors,
|
||||
so old blanket descriptions of static diagrams need workflow-level reassessment.
|
||||
The machine began without the ignored historical toolchain/cache/distributions.
|
||||
Local Rust/Cargo 1.98.1 and verified LLVM-MinGW are now installed; dependency
|
||||
fetch is pending and no current binary has been built. Native Computer Use is
|
||||
available. Isolated Capella activity/lifeline/LOOP creation was saved and checked;
|
||||
no Arcadia window is running. Candidate B1/B2/B3 and F1 edits are source-complete,
|
||||
formatted and whitespace-clean, but have no fresh compile/test/runtime pass.
|
||||
|
||||
Assumption: the selected compact architecture palette remains a promising design,
|
||||
pending real authoring evidence. Decision: repair and evaluate it before further
|
||||
redesign. Parallel independent audits identify current behavior and semantic gaps
|
||||
instead of reimplementing integrated work.
|
||||
|
||||
Open questions: which remaining menu-only behavior operations prevent a coherent
|
||||
direct workflow; which first-release semantic/parity gaps remain; and how to
|
||||
complete current Linux runtime acceptance. Root owns integration and native
|
||||
control; worker ownership is disjoint. See `STATE.md` and Gitea #1.
|
||||
|
||||
**Superseding user direction:** large horizontal creation buttons were rejected as intrusive and unprofessional. The user explicitly authorized consulting the installed Capella; that consultation has resumed and established actual compact palette, inline rename and explicit activity-allocation behavior. See [Capella 7.1 evidence and selected redesign](release-capella-basic-editing-20260907.md). The current candidate replaces creation/connection rows with one compact expandable palette and an in-place name editor. Native acceptance of the redesigned executable is pending; compactness is not assumed to prove usability. The earlier Escape hold and proposed toolbar-only correction below are superseded.
|
||||
|
||||
**Latest evidence:** basic editing full desktop584/584, bins, formatting and strictClippy passed. Native named Entity creation works at100%, but changing pending-toolbar height moves the screen placement22px. User Escape stopped the attempted Undo; native checks are paused again. Code-only toolbar correction proceeds. [Current findings](release-basic-editing-reset-20260907.md). The previous goal turn made concrete progress through implementation, integration gates and a native-discovered defect; this is not a no-progress blocker turn.
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
# Behavior authoring audit — 2026-09-10
|
||||
|
||||
Author: Codex-956atf `/root/behavior_audit`. Initial bounded read-only source and
|
||||
domain review, followed by root's separately assigned B2 implementation below.
|
||||
The audit locations/findings describe the pre-edit snapshot. No Cargo,
|
||||
application input, or new runtime acceptance was performed. Read repository
|
||||
AGENTS.md, Gitea issue #1 and
|
||||
its comments, Wiki Home, and open PR inventory (none). Coordination is in issue
|
||||
#1. Source was inspected in the shared continuation checkout while other workers
|
||||
handled architecture authoring/builds; HEAD at the final inspection was
|
||||
`1797516f460ce27f7bc92e50ea2478ec51dd49b9`.
|
||||
|
||||
The current implementation is substantially beyond static behavior diagrams.
|
||||
The remaining high-value work is making direct authoring consistent across the
|
||||
first process contribution and the non-message scenario tools. Historical
|
||||
absence claims in the early semantics audit are superseded by source7 code and
|
||||
its scoped evidence.
|
||||
|
||||
## Ordered findings
|
||||
|
||||
### B1 — A first exchange switches a new process into a less editable surface
|
||||
|
||||
**High workflow impact; source-confirmed, fresh native reproduction required.**
|
||||
|
||||
An empty Process/FunctionalChain opens the editable topology Graph. Draw its
|
||||
first Exchange contribution and it becomes a nonempty exchange-only ordered
|
||||
path. `uses_explicit_topology` now returns false, so the view changes to a
|
||||
sequence Scene without topology interaction. Tools for drawing contributions,
|
||||
connecting, reconnecting, zooming and reordering disappear. A one-contribution
|
||||
path has no link to convert, so the user must use Append exchange or separate
|
||||
Add work/control/reuse form buttons to return to the graph. Existing multi-step
|
||||
ordered paths likewise require the conversion/menu route.
|
||||
|
||||
Evidence: `behavior.rs:753` defines the inferred legacy/explicit distinction;
|
||||
`behavior/topology/view.rs:89` selects the explicit surface, `:380` begins the
|
||||
legacy branch, and `:421` builds `Scene::sequence` without an interaction
|
||||
context. `behavior.rs:887` initializes `interaction: None`.
|
||||
`app/process_canvas.rs:316` already tests first/second exchange creation and
|
||||
exact history, but that test does not assert a continuous editable surface.
|
||||
|
||||
**Reproduce:** Create an empty OA process (repeat in SA), choose Exchange and
|
||||
click the canvas, select an existing exchange and Create. Try to draw a Work
|
||||
contribution or connect from the newly created contribution without entering a
|
||||
different workflow. Repeat with an old ordered exchange-only process.
|
||||
|
||||
**Bounded next fix:** retain one interactive process surface for empty, single
|
||||
and legacy ordered paths. Preserve the legacy implied order; when a pointer
|
||||
mutation needs explicit links, include the existing `preserved_order` proposal
|
||||
in that same atomic command. Do not invent a self-link merely to force the view
|
||||
predicate, and do not migrate a document merely by opening it.
|
||||
|
||||
**Acceptance:** first and subsequent contributions remain directly editable;
|
||||
opening/selecting remains read-only; OA and SA preserve all old exchange IDs
|
||||
and implied order; insertion/connection uses one exact source/model Undo step;
|
||||
redo and save/reopen retain the result. Verify the actual viewport and visible
|
||||
tools at 1100×700 and a normal desktop size.
|
||||
|
||||
### B2 — Most scenario objects still require detached append forms
|
||||
|
||||
**High workflow impact; source-confirmed, fresh native evaluation required.**
|
||||
|
||||
Scenario canvas tools are only Select and Message. Lifeline, reuse, fragment,
|
||||
action and state-observation buttons directly start a form. Every such Start
|
||||
sets the parent to the main scenario and the insertion position to append,
|
||||
even when an operand is selected. Consequently adding an action at a visible
|
||||
operand position or inserting a reference/fragment between existing messages
|
||||
requires manually choosing an operand, creating at its end, then reordering.
|
||||
Fragments cannot be drawn around a visible sequence range. The semantics and
|
||||
generic property editing exist; the missing part is contextual canvas authoring.
|
||||
|
||||
Evidence: `behavior/scenario.rs:69` (`CanvasTool`), `:327` (Start initializer),
|
||||
`:1436` (form-launch controls), `:1565` (manual destination), `:1777` (fragment
|
||||
form). `behavior.rs:1564` handles only message drawing and selected-occurrence
|
||||
reordering. Reordering deliberately refuses operand changes at
|
||||
`behavior/scenario.rs:636`; this invariant should remain explicit.
|
||||
|
||||
The scenario shell also retains three permanent control rows, an outer page
|
||||
scroll and a separately scrolled canvas; pending forms add another scroller.
|
||||
The canvas is capped at 340 px normally and 190 px while pending (`:1535`),
|
||||
unlike the newer process remaining-height layout. This is a source-established
|
||||
space allocation; clipping or interaction failure on the current binary is
|
||||
not claimed without native observation.
|
||||
|
||||
**Reproduce:** In the teaching scenario select an ALT operand and a middle
|
||||
message. Add Action or Reuse. The form says Main scenario sequence / append,
|
||||
rather than retaining the selected operand and visible insertion point. Try
|
||||
the same task by selecting a tool and clicking the intended canvas position.
|
||||
|
||||
**Bounded next fix:** extend the existing scenario gesture/Slot adapter for
|
||||
Action, State, Reuse and Fragment; capture the exact owner, operand, before-ID
|
||||
and optional lifeline before opening its compact contextual editor. Reuse the
|
||||
existing core commands, validation, draft recovery and atomic history. Give
|
||||
scenario controls the compact, stable remaining-height shell used by processes.
|
||||
Range wrapping and cross-operand movement need a distinct explicit transaction;
|
||||
they should not be silently smuggled into ordinary reorder.
|
||||
|
||||
**Acceptance:** create each supported non-message occurrence at the clicked
|
||||
root/operand slot; select its semantic identity afterwards; Escape, scroll,
|
||||
resize, document change and rejected Apply cannot commit a stale slot; invalid
|
||||
draft text survives repair/recovery; Undo/Redo restore complete source bundles.
|
||||
Native users must complete an ALT + action + reuse task at 1100×700 without
|
||||
losing the target region or searching for offscreen submission controls.
|
||||
|
||||
### B3 — Opening a reused scenario does not retain an explicit caller return
|
||||
|
||||
**Medium workflow impact; source-confirmed.**
|
||||
|
||||
Scenario references have real identity and bindings, and an Open action works.
|
||||
However it is placed in the full sequence listing below the canvas and sends
|
||||
only `Message::Select(target)`. Selection replaces the active scenario. There
|
||||
is no scenario caller stack/Back action to restore the particular use and its
|
||||
viewport, unlike process reference navigation. The Connections inspector can
|
||||
find users manually; that does not preserve which of several callers was open.
|
||||
|
||||
Evidence: `behavior/scenario.rs:1841` / `:1893` (listing/Open),
|
||||
`app.rs:961` / `:982` (selection changes active behavior),
|
||||
`behavior/scenario.rs:155` (State has no return context). Compare existing
|
||||
`behavior/topology/direct.rs:191` (remember/back) and
|
||||
`app/process_canvas.rs:161` / `:183` (guarded Open/Back).
|
||||
|
||||
**Reproduce:** Open caller A containing two uses of the same scenario, select
|
||||
its second use in the canvas, open the definition through the listing, then
|
||||
return to that exact use and viewport. The user must reconstruct the return
|
||||
through owner selection/Connections.
|
||||
|
||||
**Bounded next fix:** selected-reference Open and Back to caller controls with
|
||||
document/owner/use identity and viewport context, using the same draft guards
|
||||
as process navigation. This is transient navigation state, not model history.
|
||||
|
||||
**Acceptance:** A→B→C and two uses of B return in order to the exact initiating
|
||||
use; renamed/deleted callers are handled safely; pending invalid edits block
|
||||
navigation without loss; no model/source/Undo entry is added by navigation.
|
||||
|
||||
### B4 — Create/Delete message semantics are not reflected in lifeline geometry
|
||||
|
||||
**Medium visual-semantic impact; source-confirmed.**
|
||||
|
||||
The core validates creation/destruction ordering, and message names distinguish
|
||||
Create and Delete. The renderer nevertheless draws every lifeline from its
|
||||
top header to the bottom of the whole scenario; lifecycle kinds only affect
|
||||
the message label. There is no birth anchor or destruction mark, so a reader
|
||||
sees the same apparent existence interval for an ordinary participant and one
|
||||
that is explicitly created and destroyed during the interaction.
|
||||
|
||||
Evidence: `behavior.rs:1016` always builds the full-height lifeline;
|
||||
`:1165` selects only a textual message-kind prefix. Core lifecycle coverage is
|
||||
in `oa-core/tests/release_scenarios.rs:459` and its loop/branch variants. This
|
||||
finding is about visualization, not missing core lifecycle validation.
|
||||
|
||||
**Reproduce:** Use a valid root sequence with Create(B), a call/reply involving
|
||||
B, and Delete(B). Inspect B before Create and after Delete; its lifeline still
|
||||
runs through both regions.
|
||||
|
||||
**Bounded next fix:** derive birth/death anchors and a destruction glyph from
|
||||
the existing occurrence identities. Handle branch-local lifetime explicitly;
|
||||
an event in one ALT operand must not falsely terminate all other alternatives.
|
||||
No persistent execution engine is needed.
|
||||
|
||||
**Acceptance:** root-sequence Create/Delete, ordinary lifelong participants,
|
||||
ALT-specific events and LOOP lifecycle warnings are distinguishable; hit tests
|
||||
still return the actual occurrence; direct insertion continues to reject invalid
|
||||
before-birth/after-death uses through the shared core boundary.
|
||||
|
||||
## Semantics and history findings that must not be reported as missing
|
||||
|
||||
- Scenario occurrences and referenced engineering definitions are distinct;
|
||||
repeated exchange uses have distinct names/IDs. Direct Call+Reply creates
|
||||
correlated occurrences in one transaction. Execution bars are derived from
|
||||
correlation and operand boundaries (`behavior.rs:1304`), not persisted proof
|
||||
of simulated execution. `scenario.rs:1026` and direct tests cover the adapter;
|
||||
the earlier scoped native results are in `release-scenario-editing.md`.
|
||||
- Source7 already provides caller-qualified process addresses, distinct repeated
|
||||
reference uses, precedence versus exchange/common-work incidence, and explicit
|
||||
interface-operation allocation identity. See `oa-core/src/model.rs:534`,
|
||||
`behavior/topology/contextual.rs`, `scenario_operation_tests.rs`, and
|
||||
`release-source7-native-acceptance-20260907.md`. The original S1/S2 absence
|
||||
findings are stale. Remaining purpose/native cases are acceptance work.
|
||||
- Shallow/deep history vertices, scope validation, creation tools and documented
|
||||
semantics already exist (`oa-core/src/state_validation.rs:187`,
|
||||
`oa-core/tests/release_states.rs:466`, `behavior/state_view.rs:111`,
|
||||
`docs/language.rst:860`). Shallow history restores the immediate substate;
|
||||
deep history restores nested configuration. Studio explicitly uses history
|
||||
per Region and does not persist running execution state. The primary Capella
|
||||
metamodel describes the corresponding containing-state behavior; this is a
|
||||
documented profile distinction, not grounds to require a simulator.
|
||||
[CapellaCommon schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/CapellaCommon.ecore).
|
||||
- Capella has distinct start/finish duration constraints and execution objects.
|
||||
Studio's local timer is not an equivalent named duration relation. It does
|
||||
have maintained generic constraints over semantic IDs. This audit did not
|
||||
falsify that alternative for a required user workflow, so it does not reopen
|
||||
a metamodel expansion already assessed in `release-scope-reconciliation-20260906.md`.
|
||||
[Capella Interaction schema](https://raw.githubusercontent.com/eclipse-capella/capella/master/m2/plugins/org.polarsys.capella.core.data.def/model/Interaction.ecore).
|
||||
|
||||
## Comparison and next action
|
||||
|
||||
The existing native Capella benchmark actually exercised palette→canvas chain
|
||||
reference/control/sequence-link creation, a scenario reference region and Open,
|
||||
and call/return insertion reusing an existing exchange. It separates observed
|
||||
controls from executed actions (`release-capella-workflow-benchmark.md:51–69`).
|
||||
These support B1–B3 as engineering-workflow comparisons rather than a demand to
|
||||
copy the Eclipse UI. Capella's official overview independently distinguishes
|
||||
functional, exchange and interface scenario perspectives, all now represented
|
||||
by Studio's typed scenario viewpoints.
|
||||
[Official feature overview](https://mbse-capella.org/features.html).
|
||||
|
||||
Prioritize B1 and the bounded slot-based part of B2; native-check those exact
|
||||
first-use tasks after root's current compilation/runtime gates. B3 is a small
|
||||
navigation follow-up. B4 should receive branch-aware visual acceptance. None
|
||||
of this report certifies current Windows/Linux behavior or transfers historical
|
||||
test/native evidence to a changed executable.
|
||||
|
||||
## Authorized B2 implementation follow-up
|
||||
|
||||
Root subsequently assigned `behavior.rs`, `behavior/scenario.rs` and only the
|
||||
ScenarioCanvas event arm in `app.rs`; Gitea #18 records this ownership under
|
||||
Codex-956atf. The separate topology worker owns B1. Current changes:
|
||||
|
||||
- A compact selector replaces the three permanent scenario tool rows. Action,
|
||||
Observe state, Reuse and empty Fragment use the same choose-tool/click-position
|
||||
workflow as messages; lifeline creation remains explicit because persistent
|
||||
horizontal placement is outside the existing schema.
|
||||
- Press captures the exact root/operand/before identity and an optional clicked
|
||||
lifeline for Action/State. Release opens the existing draft; no model mutation
|
||||
occurs until Create. The selected position remains marked in the pending
|
||||
canvas. Clicking away from a lifeline leaves it unresolved for explicit choice.
|
||||
- Existing context, scroll/bounds, Escape and pending-edit guards apply to this
|
||||
new press variant. The draft adapter validates slot and lifeline ownership
|
||||
before starting. Existing command admission handles the complete semantics;
|
||||
no separate model, schema, fragment wrapping or cross-operand reorder was added.
|
||||
- Added `direct_clicked` tests: real Scene callbacks through App for all four
|
||||
tools, root and nested slots, on/off-lifeline clicks, exact full source/model
|
||||
Undo/Redo; invalid/cross-scenario capture, all context dimensions, scroll
|
||||
invalidation, incomplete raw-draft recovery/repair; all four tools against
|
||||
nine pointer cancellation cases. These tests are written, not yet run.
|
||||
- Applied root's coordinated process-header predicate change so every
|
||||
Process/FunctionalChain uses the compact header with the topology worker's
|
||||
unified renderer. The semantic legacy/explicit predicate is unchanged.
|
||||
|
||||
Whitespace verification passed for this worker's source changes. Another worker
|
||||
read the clicked-slot adapter and reported no confirmed blocker, while requesting
|
||||
the now-added off-lifeline/cancellation cases. Formatting/build/tests await root's
|
||||
toolchain and serialized Cargo lease. No native pass is claimed: test actual
|
||||
root/operand selection, current tool visibility, pending marker/viewport and
|
||||
Create/Cancel reachability at 1100×700 and normal size, including crash recovery,
|
||||
save/reopen and exact Undo/Redo. B2's larger remaining-height shell, fragment
|
||||
range wrapping and B4 lifecycle rendering are not part of this bounded
|
||||
implementation. B3 followed as the separately assigned slice below.
|
||||
|
||||
## B3 implementation follow-up — candidate ready, execution pending
|
||||
|
||||
The selected ScenarioUse exposes **Open definition**; the target scenario exposes
|
||||
**Back to caller**. Detailed sequence-list Open uses the same guarded adapter.
|
||||
Navigation retains document, caller scenario, exact use occurrence, target,
|
||||
selected tool and independent canvas/page offsets in at most 32 transient frames.
|
||||
Nested A → B → C and distinct uses of B return to their initiating occurrence.
|
||||
Renaming retains identity; deleted or repointed uses are rejected on return.
|
||||
Property, source and behavior drafts block both directions without applying them.
|
||||
|
||||
The existing scroll observer restores only its matching document/scenario/token,
|
||||
directly in the mounted root Scrollable. It observes natural viewport clamping,
|
||||
consumes a request once and sends a correlated receipt to the App. It does not
|
||||
use a delayed global scroll task or traverse into nested scrollables. This adds
|
||||
one passive Restored variant beside the existing Scrolled early App handler; all
|
||||
other App changes remain in ScenarioCanvas. Caller navigation is neither model
|
||||
content nor recovery data and creates no model/source/history command.
|
||||
|
||||
Added regressions cover nested and distinct-use App navigation, exact unchanged
|
||||
model/source/revision/history, invalid pending drafts, four stale context axes,
|
||||
non-use targets, renamed/deleted/repointed caller references, and real mounted
|
||||
Scrollable → App flow for both scroll areas including clamping, subsequent wheel
|
||||
input, repeated receipts and changed page. Foreign document/owner and process
|
||||
views reject restoration. These tests have not run yet; root will format and
|
||||
compile the coherent candidate before acceptance.
|
||||
|
||||
Native acceptance remains: select a root or operand reference, Open definition,
|
||||
open a second reference there, return twice and inspect exact selected occurrence,
|
||||
tool and both scroll positions. Repeat from the detailed list with two uses of
|
||||
one target, a resized window and an invalid draft. Verify no dirty/history change
|
||||
from navigation. The separate remaining-height layout slice must retain these
|
||||
events and observer restoration when moving the scenario workspace.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Native Capella scenario benchmark — 2026-09-10
|
||||
|
||||
Codex-956atf /root used the installed Capella 7.1 executable through Computer
|
||||
Use in an isolated `.local/capella-reference-20260910` workspace. The generated
|
||||
project is `Behavior workflow benchmark`; no existing user model was opened.
|
||||
This evaluates reference-product behavior, not the current Studio binary.
|
||||
|
||||
At the observed 1010×760 window size:
|
||||
|
||||
- Operational Analysis → Define Operational Activities and describe Interactions
|
||||
exposes creation of activity breakdown, interaction and activity-scenario views.
|
||||
The scenario workflow created `[OAS] Response workflow` and its capability.
|
||||
- The right scenario palette exposes Activity, Activities, Interaction,
|
||||
Interaction with return branch, Reference, LOOP and Operand. Activity is a
|
||||
direct point-placement tool: clicking the canvas created a represented activity
|
||||
and a lifeline. The lifeline is a separate InstanceRole, not the work definition.
|
||||
- Selecting LOOP and dragging a region across the lifeline created a visible
|
||||
combined fragment. Its tooltip explains the repeated scenario part and covered
|
||||
lifelines. The actual drag created one operand and explicit start/end records.
|
||||
- Selecting the new object expanded formatting/context controls and moved the
|
||||
canvas vertically. Preserve direct interaction and coverage meaning in Studio
|
||||
without copying this unstable canvas origin or the clipped palette labels.
|
||||
|
||||
Ctrl+S saved the model, and the saved XML independently confirms:
|
||||
|
||||
| Record | Identity / relationship |
|
||||
|---|---|
|
||||
| Activity | `04b58488-e33b-4693-ae8d-6f830cbc0cf3` |
|
||||
| Scenario | `49f702e0-1e0d-45c2-a0e8-e40545ce5b6c`, kind INTERACTION |
|
||||
| InstanceRole | `29115069-194d-4c1a-ac2c-ee86486a04fb`, represents the Activity |
|
||||
| LOOP | `1b4a102e-4c20-4e92-bd4f-71e6b677d2fa` |
|
||||
| Operand | `ac05a0af-60d1-48f2-9548-17214d3ebdc1`, covers the InstanceRole |
|
||||
| Fragment interval | start `8d988d2b-c191-46b2-8c92-1ff1e62251cb`, end `a8c26302-a5bb-49c0-8e7c-d2fe8026fe1a` |
|
||||
|
||||
These observations support scenario tool/point authoring and explicit occurrence
|
||||
versus definition identity. They do not demonstrate wrapping existing messages,
|
||||
cross-operand movement, lifecycle drawing or successful scenario reuse; those
|
||||
operations were not exercised. Studio's current clicked-slot improvement creates
|
||||
an empty typed fragment using its existing occurrence/branch model. Persisted
|
||||
horizontal lifeline arrangement and drag-range wrapping need separate design and
|
||||
acceptance if adopted; this benchmark does not silently introduce either.
|
||||
|
||||
After saving, Alt+F4 requested a clean Capella close. Root checks process absence
|
||||
before reclaiming memory for compilation; no forced termination was used.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Independent release end-state audit — 2026-09-10
|
||||
|
||||
Reviewer: **Codex /root/release_audit**. Base HEAD `1797516f460ce27f7bc92e50ea2478ec51dd49b9`; concurrent palette/integration work is excluded from acceptance. Read PROJECT_GOAL.md, the supplied request, AGENTS.md, Gitea issue #1/comments, Wiki Home and the empty open-PR inventory before auditing. Coordination is recorded in issue #1.
|
||||
|
||||
**Verdict: first-release completion is not supported yet.** Current source supplies substantial OA/SA semantics and shared source/GUI/REST infrastructure. The earlier missing communication means, selective views, coverage diagnostics, contextual assembly and operation identity are implemented; historical absence findings must not be repeated as current defects. A concrete trace-inspector defect remains below, and current native/platform/package acceptance is still incomplete.
|
||||
|
||||
This was read-only implementation/specification inspection. No Cargo, native automation, deployment or runtime test ran. Test names below identify evaluators that exist, not fresh passing results. Root owns current builds/runtime and the separate behavior audit owns process/scenario ergonomics.
|
||||
|
||||
## Requirements matrix
|
||||
|
||||
| User outcome | Current source evidence | Acceptance assessment |
|
||||
|---|---|---|
|
||||
| Rust/Iced desktop on Windows and Linux | Three Rust crates; desktop Cargo.toml selects Iced 0.14, WGPU, X11 and Wayland. README gives native build paths. | Implemented architecture; current Windows palette rebuild/native acceptance and current Linux runtime remain gates. An ELF cross-build is not a Linux GUI trial. |
|
||||
| Useful OA and SA engineering semantics | model.rs has operational roles/activities/missions/processes, system boundary/functions/chains, typed data/interfaces/ports, states, scenarios, constraints and 25 relation kinds. validation.rs, ports.rs and data.rs enforce common rules. | No new fundamental absence established outside the separate behavior audit. Do not infer full Capella conformance. LA/PA and execution remain outside the user's current layer scope. |
|
||||
| Equivalent persistent graphical/text models | Project, validated Command boundary, source_project updater and desktop Document couple accepted source to the same model. forms.rs exposes typed records; dedicated workflows handle richer constructs. | Strong implementation coverage; **F1 exposes a broken typed-history inspector path**. `every_demo_record_has_a_lossless_graphical_draft` checks existing demo values, not every exposed constructor/add operation. |
|
||||
| Strict, unambiguous reST contract | docs/language.rst defines grammar, identity, invariants, lifecycle, migration and graphical obligations; docs/api.rst specifies REST. | Substantial contract; **F2 catalog inconsistencies** need correction. Source/header and package documentation should agree at final freeze. |
|
||||
| Human/AI/Git-friendly source with highlighting and lint | Modular includes, nested declarations, stable aliases/UUIDs, source-preserving updates and exact source history; highlight.rs adapts core tokenizer; source_workspace/source_navigation retain and locate drafts. | Implemented, with source7 source/lifecycle tests. No new text-loss counterexample found in this bounded audit. LSP, completion and semantic Git merge were not explicit requirements. |
|
||||
| Easy bidirectional OA/SA transition | transitions.rs maps dependencies/counterparts in either direction; semantic review supports scalar, constructor, keyed/ordered, attached and artifact decisions. Communication boundary ports and technical-operation omission are explicit. | Core/API paths and tests exist; actual complete engineering transition and revision/history workflows still require their remaining native cases. **F2 help wording misstates Manual-trace behavior.** |
|
||||
| Trace arbitrary development artifacts | ArtifactLink stores owner/relation/URI/revision/fragment; shared identity/reference maintenance, forms and searchable traceability inventory; server artifact/trace queries plus ordinary transactions. | Required reference model is present. No artifact fetching, executable adapter or plugin ABI is necessary to establish this requirement. Native final-tree evidence must include save/reopen and a cross-tool REST edit. |
|
||||
| Deployable service and clear REST for automation | routes.rs exposes project/model/source-bundle, lists/query/validation, commands, transitions and semantic review; store persists coupled representations with ETags. Dockerfile and systemd recipe exist. | Source7 HTTP test explicitly exercises instance/operation edits, parity and restart. Current Linux server suite and exact release build/deployment verification remain outstanding; recipes do not prove deployed execution. |
|
||||
| Intuitive nested diagrams and consistent editing | Distinct semantic ownership, allocation and placement; independent saved views, ports/routes, filters and duplication; current compact palette under repair. | Cannot accept from source. Root's native authoring evidence and separate behavior audit must cover selection, draft/cancel/history and wide/minimum size. |
|
||||
| Built-in concise navigable guide | guide.rs has searchable topics, related links, destination links and worked source7/transition/evidence material; bundled teaching source exists. | Implemented; guide still contains older control wording. Remaining complete teaching workflow and current control-label/native checks must close. |
|
||||
|
||||
## Material findings
|
||||
|
||||
### F1 — P2: the trace-history inspector offers an Add action that cannot create a valid fact
|
||||
|
||||
The generic trace inspector recursively displays `baseline.semantic.facets`. Its array control always offers **+ Add** (`crates/oa-desktop/src/forms.rs:1916–1930`). `Message::FieldArray` forwards directly to `Draft::array` (`app.rs:1720`). `edit_array` has special constructors for bindings, values, routes, waypoints and port anchors, but none for facets: its fallback appends the nil UUID **string** (`forms.rs:622–650`). `SemanticBaseline.facets` instead requires `Vec<FacetReview>` (`crates/oa-core/src/semantic/types.rs:258`), a closed typed enum. `Draft::command` deserializes the trace as-is (`forms.rs:600`). The generated draft therefore cannot become a valid EditTrace.
|
||||
|
||||
Small reproduction for root's serialized test gate: select a trace with `baseline.semantic = Some(SemanticBaseline::default())`, call `Draft::from_project`, then `array("/baseline/semantic/facets", None, 0)` and `command()`. The Add produces a UUID rather than a facet; command must reject. Removing the bad row or reverting recovers the draft, so this is not an established stored-data corruption finding.
|
||||
|
||||
The specialized semantic-review workflow can create reviewed current observations and reset exact history; that does not make this separate advertised Add control valid. Optional semantic history is also omitted when absent, so the ordinary inspector cannot initialize it through that path. The normative obligation explicitly includes typed trace baselines (`docs/language.rst:1959–1963`).
|
||||
|
||||
**Next action:** choose one coherent history-editing surface. Replace the generic invalid constructors with an intentional typed workflow, or route the inspector to the existing review/reset tools while defining which history operations they support. Verify all displayed history controls against absent, empty and nonempty histories, including historical references whose live objects were deleted. Do not claim that a generic recursive form proves full constructor parity.
|
||||
|
||||
### F2 — P2: the normative catalogs and guide give inconsistent transition information
|
||||
|
||||
- `docs/language.rst:1192–1193` lists Exchange traces only as Interaction → FunctionalExchange, although its communication section (`:341`) and `transitions.rs::exchange_counterparts` admit CommunicationMean → BoundaryExchange. The reference table should describe both admitted pairs.
|
||||
- The same document's element catalog (`:94–145`) omits ExchangeCategory, and the relation catalog (`:232–303`) omits ContextualLink. Both have detailed definitions later, so their semantics are implemented/documented rather than absent; the purported catalogs are incomplete entry points for an independent implementation.
|
||||
- `guide.rs:658` says a Manual trace records correspondence “without propagating properties.” Existing-counterpart transition logic finds any endpoint-matching trace and applies reviewed name/description changes without a Manual-kind exemption (`transitions.rs:1055–1153`). Explicit Capability ↔ Mission Manual mappings are deliberately supported (`language.rst:1218`). Clarify that ordinary edits never synchronize automatically, while an explicit supported review can propagate selected properties; do not imply Manual is a no-update policy.
|
||||
- `docs/scope.md:16` still labels the implemented source profile `arcadia 6`; core/schema and the normative contract are version 7. README also retains older review labels and pending claims that newer scoped STATE evidence supersedes. Existing guide Activity/Function steps (`guide.rs:265`) describe the older Create-toolbar workflow while palette work is actively changing that surface.
|
||||
|
||||
**Next action:** synchronize the tables, scope and task instructions against the accepted controls; regenerate the checked-in HTML from corrected reST and rerun the existing strict documentation/OpenAPI checks. Do not change transition semantics merely to match stale wording.
|
||||
|
||||
### F3 — P1 release gate: current platform, deployment and full-workflow evidence is incomplete
|
||||
|
||||
`harness/STATE.md` explicitly records a frozen Linux GUI cross-build with no actual Linux GUI runtime (`:57`), an earlier Linux server HTTP 50/51 result with a subsequently corrected evaluator (`:44`), and pending native purpose/teaching/hosted/compact cases. The Windows development preview (`:46`) predates current palette changes and is not the optimized final release. README correctly calls final suites, optimized/MSRV builds and current packages outstanding (`:156`). Root's new checks may supersede particular rows after this audit.
|
||||
|
||||
This is missing release evidence, not proof that Linux, REST or the implemented workflows are broken. It still prevents meeting the user's explicit build/launch, real-workflow, current-platform and final-review criteria. The Docker builder pins Rust 1.90 while normal rust-toolchain.toml selects stable; final compatibility must be checked against the actual locked tree and intended package, rather than inferred from a different frozen snapshot.
|
||||
|
||||
**Next action:** finish the current Windows authoring slice, then freeze one candidate revision for appropriate complete tests, strict checks, optimized desktop/server builds, current Linux runtime and representative cross-representation/REST restart cases. Assemble packages from that accepted tree and verify examples, reST/HTML/OpenAPI, notices, runtime dependencies, clean launch and checksums. Respect prior runtime/approval boundaries; a stopped guest or denied workaround is not successful acceptance.
|
||||
|
||||
## Highest-value completion sequence
|
||||
|
||||
1. Reproduce and correct F1; retain focused failure/repair evidence. Finish root's current palette compile/native gate and address the separate behavior audit's verified findings.
|
||||
2. Close the already documented complete teaching story: OA responsibility/communication → reviewed SA contribution and interfaces → behavior/degraded state → artifact evidence → source edit → conditional REST edit/reload and stale-write rejection → save/reopen/history. Use exact whole-model and authored-source comparisons, not screen appearance alone.
|
||||
3. Correct F2 and prune misleading current-status wording without deleting historical evidence. Keep the normative contract explicit about intentional asymmetry: semantic ownership versus allocation, OA abstraction versus SA technical ports, and reviewed counterpart differences.
|
||||
4. Complete F3 on a frozen candidate and obtain fresh independent runtime/UX plus final requirements review. No additional broad metamodel subsystem is justified by this audit alone.
|
||||
|
||||
At the end of the initial audit, only this report had been written. The subsequently authorized F1 implementation is recorded below; no fresh runtime/test pass is claimed here.
|
||||
|
||||
## Bounded F1 remediation recommendation
|
||||
|
||||
Root requested a decision on generated review history versus arbitrary facet constructors after receiving this audit.
|
||||
|
||||
**Use the existing semantic-review/history surface, with a distinct typed stored-observation editor.** Removing generic Add and routing to inspection/reset safely removes the immediate trap, but inspection/reset alone does not discharge the current parity contract. Section 5.1 deliberately admits historical observations independently of current object applicability, including references whose live objects are gone; section 8 explicitly requires graphical editing of trace baselines. A workflow that can only acknowledge current live facts cannot express every admitted source-authored historical value. Changing the spec to declare those properties uneditable would narrow the requested equivalence.
|
||||
|
||||
The bounded implementation need not build another reconciliation engine:
|
||||
|
||||
- Choose a closed facet selector and its exact typed row keys, using the existing shared catalog. Never append an arbitrary default FacetReview.
|
||||
- Edit canonical OA and SA Observed branches with their selector-specific types. NotApplicable, Value(none), and absence of the facet remain distinct. Current observations can be presented as a deliberate starting choice, not silently acknowledged on Add.
|
||||
- Keep stored-observation editing separate from Keep/Take/live candidate edits. It changes the trace history only and must say so. Existing inspect/reset remains the ordinary workflow.
|
||||
- Historical UUID input must retain deleted identities and allow syntactically valid exact keys; live-object-only pickers are insufficient. Use the existing historical validator, not live-reference applicability, for historical admission.
|
||||
- Stage a complete typed candidate; validate and apply one EditTrace through the existing Document/source/history boundary. An incomplete selector, key or observation keeps Apply disabled or fails with a located editable draft. Source writeback and Undo/Redo remain shared.
|
||||
|
||||
Acceptance should cover absent history, an empty facet list, one scalar, one optional value, NotApplicable, a keyed attachment/artifact observation with deleted UUIDs, changing an observation's constructor, duplicate/overlapping-invalid history, and exact source/JSON Save/Undo/Redo. Prove the original nil-UUID Add cannot occur. This is a finite history-authoring slice over existing types, not a new metamodel or automatic synchronization feature.
|
||||
|
||||
## F1 implementation candidate — Codex-956atf /root/release_audit
|
||||
|
||||
Root authorized the bounded implementation in `crates/oa-desktop/src/forms.rs`, `forms/history_fields.rs`, and `forms/history_fields/tests.rs`. No core API or app handler was changed. The trace inspector replaces its recursive baseline form with a collapsed **Edit stored history (advanced)** surface. It initializes absent prose/semantic baselines, chooses a facet family and leaf explicitly, edits keyed selectors and both typed observations, and removes/reorders rows. Optional values, NotApplicable, booleans, integers/reals, enum and compound constructors, exact strings and historical UUIDs are separate controls. Current counterpart observations are an explicit opt-in action using `capture_facet`; the editor changes no counterpart.
|
||||
|
||||
**Design decision:** reuse the existing checked-in OpenAPI TraceBaseline/FacetReview descriptor as finite historical field-shape metadata, cached once. This already documents the admitted serialized constructors, so using it here avoids a second manually maintained semantic catalog or new core metadata API. This is not an all-product REST-schema form generator. The supported local refs, oneOf/anyOf, fixed objects, arrays and primitive shapes are bounded by depth 32; list rendering pages 12 rows. Unsupported metadata produces a visible unavailable-field message. The real Rust types and `validate_semantic_baseline` remain authoritative for admission; the descriptor supplies no replacement validator.
|
||||
|
||||
**Deliberate draft behavior:** a newly chosen facet has both observations NotApplicable and cannot be committed until the engineer supplies an admitted observation. New UUID slots are empty, never a made-up nil identity. Historical typed Add replaces the generic UUID-array fallback. Scoped shape/list events preserve unrelated incomplete inputs. Constructor caches follow row movement/removal and recovery; exact-string mode also converts inactive textual buffers transactionally. Replacing or clearing a whole history subtree discards its inactive constructor buffers so later new facts cannot revive old data. Ordinary Apply remains the existing EditTrace/Document/source transaction.
|
||||
|
||||
Ten targeted tests are present. They cover absent/empty/existing history; descriptor-derived complete witnesses for every offered scalar/keyed fact against actual FacetReview deserialization and historical validation; rejected NA/NA and duplicates with unchanged Document snapshots; deleted UUID keys/observations; numeric limits and invalid raw repair; compound values and constructor switching; TypeRef's nested primitive picker; exact Unicode/quoted strings and literal keys; cache relocation/recovery; clear/recreate isolation; source/JSON round-trip and exact one-step Undo/Redo including retained source comments.
|
||||
|
||||
**Verification status:** `git diff --check` passes for the candidate (an unrelated generated HTML line-ending warning is present). No Cargo, rustfmt, native GUI or integration suite was run by this worker; root owns the serialized build/toolchain lease. F1 is therefore a ready source candidate, not an accepted fix yet. Root should run targeted history tests, the relevant desktop suite and strict lint, then exercise expanded history controls at normal/minimum width, typed Add/repair, paging, apply/reopen and Undo in the actual desktop. F2 and shared decision/wiki synthesis remain root-owned. F3 remains open.
|
||||
|
||||
**Independent-review follow-up:** behavior_audit found a real defect in snapshot `01bb52a`: the reused constructor-cache key mapped null, numbers, booleans and arrays to the same empty tag, also colliding with empty UUID input. The nested optional picker consequently restored the old branch immediately instead of switching; earlier tests that injected complete Observed values skipped that event. The bounded follow-up adds history-only typed constructor identities (including distinct enum and tagged-object variants) while retaining other property-editor behavior. Two additional tests derive the visible nested picker choices and dispatch their actual FieldShape events: absent/present integer with invalid raw recovery and exact Value(None); empty UUID/absence with deleted identity; optional list/absence with nested Integer/String constructor retention and recovery. The resulting accepted models still receive exact Document/source/Undo checks. Twelve history tests are now present. Whitespace check passes; compilation, execution and native acceptance remain pending with root.
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
IyB2MiBnaXQgYnVuZGxlCi1lYjUxNjFiMjA3ZjY5YzVkZDExMGRkMWRhY2U4MjdlNDVlNmE3YzBjIFByZXBhcmUgc2NvcGVkIGxpZmVjeWNsZSBwcm9qZWN0aW9uIGFuZCBhbGlnbiBhdXRob3JpbmcgZ3VpZGUKNGVjYWQ3ODQzM2JkZmNmMjA3OTJmMDczOTI0YzgwZDhiYTM3YzI1MSBIRUFECgpQQUNLAAAAAgAAAAmaD3iclYxBasQwDEX3PoUuULBTy56BUgo9Qk+gyAoxOFGwlTJz+0mP0M1/m/e+dRFIKaGkHEjmhJEQw0wpLEuc6J2EPGUWpOjdQV12A5kxpDBPPi/pzlhKCP6aQiy3KUu8zq7Es6PTVu3wrUUe8MF/+GrK1FYd9gkh3+4+IU4R3nz23rFuWzWTfyTuR6SAHlZ1pwZrHab9Cb/UThmwdN1AHkerXA1Y92H95MsY7gVQV0+A8AJZ6jpicljDJLKEbnIkzdT7+qm0OXicO8l6knXDVkaRBMlA5Wbxd/8ltOsfbrdZqLNifQvz4pOMjADYiw06qwZ4nDMxAAKF/ETd5PyiVIaVx+V07E+q8bJ9nHjgWrRc8hO5229MYCpSUouzS/ILGAqFYp9XyMx+YvVSZKPCuh1rl166fQuuqDi1qCy1iGHm7X1W4ldiCwz+iCyWX6vDbqzb2wYAtTIqwPkBf8NPh3MwuZu+WkThKV7cm9KQBnd4nMvImBAiknvN9vvWP0LHVVelxru02r+UMOfvAwCZ3Qs68QKk8JYjVAXMxE/7rlNw1d7YQD7c+nicASEA3v+TDZMNsIoCFO9SGeXTGkCOKkCS8KmSXkz3AKRls54C9QPurg6n9wToqJzXDifiF8Zp1rSAoexmikyo6nica2RqZJpQYHtW2/zf4skX5YMMjb5/vXLoycGkg79NDIBAISOzuCS/qDI+LTM1J6WYoTjrZFN0XpFabqqgmYN3glKo/BuWiWtDAH85HjLwCVSAW93hA+SHXY27DUR9UXvBvpYFeJzbfJVx2TXGDZMEJk/wYJOIz00s0KgprlEoSi3OzylL1SjW1MvP1tCc3OghygWXLJ4cIKTmq8mlgAT0EvMqNWrKEnNKU4EqILRecn5eSWJmXrGGWlZxfp6ihpJffoljQUFOZnJiUk6qkqYm3IzJB3nENj8VOBcJANd1L2ikAnicMzQwMDMxUShJLS4p1isqZhBYzzaRz9e98KTi2blyd7z4o7avVwcAzPUMjfgsI7QAcoZEwmyxLxPf6qjynqJP48l4nI2SP2/TQBjGlUotqKUoqEWqogbeXPhjqxFmYghNpIoO7dLwp+0AQtbFfo0PuXfu3TlBiqyyMt+KQHwCkBiZ2EAMfISKL8GG4OykpWTiXXzn596fnnuf+/K78ubVzIef0ZFINROcJn6WsdCnPPQ1vtC+QrS7vkIeoD9kOhaZ9hkfINeMP/OFVeSAFq3KcWE0D7YiISGigQbG4QnZQhqi9O5TaXtIC8iG1KyQPYkDhkPvof2oC5ZAnk4Aphc3L42KM7knKHHvjv++C5uzNxQL0Xw6XzNXN+dXfTxsOIVFRwUxHtAW3HZvZXwoaeqYw8e11XFnUQlqGLu1FxrQJENTQTBf52pmC6tmF9eqpDfRodSJeX/vOimX0IG+pDyIy3k4U5zPu8vm5c2FerlrwXMleMMZkf0S0gaeJUnumtfV5uIZqz41x3dqR6d23bPe3dap76JKUru9Y0H/CoRCmlA75iIrb29vexNUTFMEpoALDTpG2BF6I00TFtB+goA8O4DApqVlFmgxI4l50FxusGicWKcDU4GdRPK2Ciafq187mdF6r3ww69tht9t1yqv/Hf2PK0vmmF+eneR2cWkxB0wUTsGcadgjLe2r+h9gJTff9+uml577+Cv6tvIHQ+/uihpCoA9oYJtpiVZ7snfB2u7+eiU7
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user