Integrate process, scenario and typed history authoring checkpoint

This commit is contained in:
2026-09-10 11:14:43 -07:00
22 changed files with 4093 additions and 166 deletions
+81 -5
View File
@@ -1100,11 +1100,13 @@ impl App {
// still validates document, scenario and revision before accepting it.
if matches!(
&message,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Scrolled(..))
| Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
)
Message::ScenarioCanvas(
crate::behavior::scenario::CanvasEvent::Scrolled(..)
| crate::behavior::scenario::CanvasEvent::Restored(..)
) | Message::TopologyCanvas(
crate::behavior::topology::CanvasEvent::Scrolled(..)
| crate::behavior::topology::CanvasEvent::Viewport(..)
)
) {
return self.handle(message);
}
@@ -1362,6 +1364,18 @@ impl App {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::Restored(_, token, area, offset) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document && current.scenario == context.scenario
}) && self
.behavior
.scenario
.restored(context, token, area, offset)
{
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
return Task::none();
}
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Inner and outer scroll notifications may come from the
// same frame. Accept both across the first epoch bump,
@@ -1380,6 +1394,45 @@ impl App {
return Task::none();
}
match event {
CanvasEvent::OpenUse(_, occurrence) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status = "Apply or cancel pending edits before opening a referenced scenario.".into();
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.scenario);
let target = state.open_use(self.project(), context, occurrence);
self.behavior.scenario = state;
match target {
Ok(target) => {
self.select(target);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
Err(error) => self.status = error,
}
}
CanvasEvent::Back(_) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty()
{
self.status =
"Apply or cancel pending edits before returning to the caller."
.into();
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.scenario);
let caller = state.back(self.project(), context);
self.behavior.scenario = state;
if let Some(caller) = caller {
self.select(caller.occurrence);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
if let Some(context) = self.scenario_context() {
self.behavior.scenario.restore_caller(context, caller);
}
} else {
self.status =
"The calling scenario reference is no longer available.".into();
}
}
CanvasEvent::Select(_, id) => {
if !self.behavior.has_pending()
&& crate::behavior::selected_behavior(self.project(), id)
@@ -1405,6 +1458,29 @@ impl App {
return Task::none();
}
match mutation {
CanvasEvent::BeginOccurrence {
tool,
slot,
lifeline,
..
} => {
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.begin_occurrence(
self.project(),
context,
tool,
slot,
lifeline,
);
self.behavior.scenario = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Complete the selected behavior at the highlighted sequence position.".into();
}
Err(error) => self.status = error,
}
}
CanvasEvent::Begin {
slot,
sender,
@@ -279,6 +279,166 @@ fn architecture_cancelled_gesture_cannot_commit_a_later_named_draft() {
assert!(app.architecture.is_some());
}
#[test]
fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
use iced::{keyboard, mouse, widget::canvas::Program};
let mut app = setup(Layer::Oa);
send(
&mut app,
Event::Start(Kind::Entity, Some(Position { x: 200., y: 120. })),
);
send(&mut app, Event::Name("Response team".into()));
send(&mut app, Event::Submit);
let before = app.document.bundle().clone();
let element = app.selected;
let node = app.selected_node;
let diagrams = app.project().diagrams.clone();
let camera = (app.zoom, app.pan);
let canvas = crate::diagram::DiagramCanvas {
project: app.project(),
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
editable: true,
placing: false,
connection: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
let mut state = crate::diagram::State::default();
let key = iced::widget::canvas::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F2),
modified_key: keyboard::Key::Named(keyboard::key::Named::F2),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::F2),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
});
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
let _ = canvas.update(
&mut state,
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
cursor,
);
let message = canvas
.update(&mut state, &key, bounds, cursor)
.expect("focused F2 opens the selected semantic name")
.into_inner()
.0
.unwrap();
let _ = app.update(message);
assert!(app.architecture.as_ref().unwrap().inline());
send(&mut app, Event::Name(" Emergency team 雨 ".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none());
assert_eq!((app.selected, app.selected_node), (element, node));
assert_eq!(
app.project().elements[&element].name,
" Emergency team 雨 "
);
assert_eq!(app.project().diagrams, diagrams);
assert_eq!((app.zoom, app.pan), camera);
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
let context = app.route_context().unwrap();
send(
&mut app,
Event::Rename(context, element, Position { x: 200., y: 120. }),
);
send(&mut app, Event::Name("Cancelled name".into()));
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn architecture_contextual_allocation_creates_work_relation_and_node_in_one_undo() {
for (layer, performer_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
let mut app = setup(layer);
send(
&mut app,
Event::Start(performer_kind, Some(Position { x: 20., y: 20. })),
);
send(&mut app, Event::Name("Control team".into()));
send(&mut app, Event::Submit);
let performer = app.selected;
let before = app.document.bundle().clone();
send(
&mut app,
Event::Allocate(work_kind, performer, Some(Position { x: 55., y: 90. })),
);
send(&mut app, Event::Name("Assess pressure".into()));
assert_eq!(app.document.bundle(), &before);
send(&mut app, Event::Submit);
assert!(app.architecture.is_none(), "{}", app.status);
let work = app.selected;
assert_eq!(app.project().elements[&work].parent, None);
assert!(
app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation
&& r.source() == performer
&& r.target() == work)
);
let node = &app.project().diagrams[&app.active_diagram.unwrap()].nodes
[&app.selected_node.unwrap()];
assert_eq!(node.position, Position { x: 55., y: 90. });
let accepted = app.document.bundle().clone();
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
}
}
#[test]
fn architecture_rejected_submission_keeps_inline_name_visible_and_invalid_click_is_ignored() {
let mut app = setup(Layer::Oa);
send(&mut app, Event::Start(Kind::Entity, None));
let context = app.route_context().unwrap();
send(
&mut app,
Event::Click(
context,
Position {
x: f32::NAN,
y: 30.,
},
),
);
assert!(app.architecture.as_ref().unwrap().unnamed_tool());
send(&mut app, Event::Click(context, Position { x: 40., y: 30. }));
send(&mut app, Event::Name("Retained team".into()));
// A replaced document must not accept a queued draft submission.
app.canvas_document = Id::new_v4();
let before = app.document.bundle().clone();
send(&mut app, Event::Submit);
let draft = app.architecture.as_ref().unwrap();
assert!(draft.inline());
assert!(draft.error.is_some());
assert!(
matches!(&draft.saved.intent, architecture_authoring::Intent::Create { name, .. } if name == "Retained team")
);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Escape);
assert!(app.architecture.is_none());
assert_eq!(app.document.bundle(), &before);
}
#[test]
fn basic_transition_explicit_overview_focus_wins_over_an_already_populated_architecture() {
use crate::workflow::{Event as W, Workflow};
+390 -9
View File
@@ -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
+59 -4
View File
@@ -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] {
+28 -62
View File
@@ -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
+101 -4
View File
@@ -16,6 +16,7 @@ use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
mod context_fields;
mod data_fields;
mod history_fields;
mod port_fields;
mod view_fields;
@@ -50,6 +51,14 @@ pub(crate) fn set_shape_value(record: &mut Value, path: &str, value: Value) -> R
*target = value;
return Ok(());
}
if history_fields::is_path(path)
&& history_fields::has_field(path)
&& let Some((parent, field)) = path.rsplit_once('/')
&& let Some(fields) = record.pointer_mut(parent).and_then(Value::as_object_mut)
{
fields.insert(field.into(), value);
return Ok(());
}
// Ordinary messages omit operation=None in source/JSON. Authoring that one
// optional field must not require changing the historic record encoding.
if let Some(parent) = path.strip_suffix("/operation")
@@ -75,6 +84,7 @@ pub struct Draft {
port_contract_drafts: BTreeMap<String, Value>,
pub value_browsers: BTreeMap<String, ValueBrowser>,
pub reference_browsers: BTreeMap<String, ReferenceBrowser>,
history: history_fields::State,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -156,6 +166,7 @@ impl Draft {
})
.collect();
draft.port_contract_drafts = saved.ports;
draft.history.open = draft.kind == RecordKind::Trace;
Ok(draft)
}
pub fn from_project(project: &Project, selected: Id) -> Option<Self> {
@@ -197,6 +208,7 @@ impl Draft {
port_contract_drafts: BTreeMap::new(),
value_browsers: BTreeMap::new(),
reference_browsers: BTreeMap::new(),
history: Default::default(),
};
draft.rebuild();
Some(draft)
@@ -311,9 +323,38 @@ impl Draft {
.map_or(true, |value| value != self.baseline)
}
pub fn set_exact(&mut self, enabled: bool) -> Result<(), String> {
self.value = self.materialize()?;
let value = self.materialize()?;
let mut history_inputs = Vec::new();
if enabled != self.exact_strings {
for (key, (_, inputs)) in &self.constructor_drafts {
if self.kind != RecordKind::Trace || !history_fields::is_path(&key.0) {
continue;
}
for (path, input) in inputs {
if free_string(path) {
let raw = if enabled {
serde_json::to_string(&input.raw).expect("String serialization")
} else {
serde_json::from_str::<String>(&input.raw).map_err(|_| "Repair the quoted text in the retained history constructor before changing escaped-string mode.".to_owned())?
};
history_inputs.push((key.clone(), path.clone(), raw));
}
}
}
}
self.value = value;
self.exact_strings = enabled;
self.rebuild();
for (key, path, raw) in history_inputs {
if let Some(input) = self
.constructor_drafts
.get_mut(&key)
.and_then(|(_, inputs)| inputs.get_mut(&path))
{
input.editor = text_editor::Content::with_text(&raw);
input.raw = raw;
}
}
Ok(())
}
pub fn input(&mut self, path: &str, value: String) {
@@ -329,6 +370,29 @@ impl Draft {
}
}
pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> {
if self.kind == RecordKind::Trace {
if path == history_fields::TOGGLE {
self.history.open = value
.as_bool()
.ok_or("Choose whether to show stored history.")?;
return Ok(());
}
if let Some(path) = path.strip_prefix(history_fields::PAGE) {
self.history.pages.insert(
path.into(),
value.as_u64().ok_or("Choose a history page.")? as usize,
);
return Ok(());
}
if let Some(path) = path.strip_prefix(history_fields::VARIANT) {
return self.variant_scoped(path, value);
}
if history_fields::is_path(path) {
self.constructor_drafts
.retain(|(root, _), _| !within(root, path));
return self.shape_scoped(path, value);
}
}
let mut candidate = self.materialize()?;
let value = if path.ends_with("/Port/contract") {
port_fields::switch_contract(
@@ -369,7 +433,8 @@ impl Draft {
|| path.ends_with("/Occurrence/body")
|| path.ends_with("/Event/body")
|| path.ends_with("/message_kind")
|| path.ends_with("/ContextualLink/purpose");
|| path.ends_with("/ContextualLink/purpose")
|| (self.kind == RecordKind::Trace && history_fields::is_path(path));
if !constructor {
return self.shape_scoped(path, proposed);
}
@@ -448,7 +513,7 @@ impl Draft {
.len();
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
self.rebuild_retaining(|old| {
let relocate = |old: &str| {
let Some(tail) = old.strip_prefix(&format!("{path}/")) else {
return Some(old.to_owned());
};
@@ -474,7 +539,24 @@ impl Draft {
} else {
format!("{path}/{position}/{rest}")
})
});
};
self.rebuild_retaining(relocate);
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
let previous = std::mem::take(&mut self.constructor_drafts);
for ((root, tag), (value, inputs)) in previous {
if !root.starts_with(&format!("{path}/")) {
self.constructor_drafts.insert((root, tag), (value, inputs));
continue;
}
if let Some(root) = relocate(&root) {
let inputs = inputs
.into_iter()
.filter_map(|(path, input)| relocate(&path).map(|path| (path, input)))
.collect();
self.constructor_drafts.insert((root, tag), (value, inputs));
}
}
}
Ok(())
}
pub fn append_scoped(&mut self, path: &str, value: Value) -> Result<(), String> {
@@ -510,6 +592,9 @@ impl Draft {
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
return self.array_scoped(path, index, direction);
}
let mut candidate = self.materialize()?;
edit_array(&mut candidate, path, index, direction)?;
self.value = candidate;
@@ -517,6 +602,9 @@ impl Draft {
Ok(())
}
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
if self.kind == RecordKind::Trace && history_fields::is_path(path) {
return self.append_scoped(path, value);
}
let mut candidate = self.materialize()?;
candidate
.pointer_mut(path)
@@ -625,6 +713,9 @@ fn edit_array(
index: Option<usize>,
direction: i32,
) -> Result<(), String> {
if history_fields::is_path(path) && index.is_none() {
return Err("Choose a typed stored fact or list value before adding it.".into());
}
let items = value
.pointer_mut(path)
.and_then(Value::as_array_mut)
@@ -807,6 +898,9 @@ fn multiline(path: &str) -> bool {
)
}
fn free_string(path: &str) -> bool {
if history_fields::is_path(path) {
return history_fields::free_string(path);
}
multiline(path)
|| matches!(
path.rsplit('/').next(),
@@ -1728,6 +1822,9 @@ fn property<'a>(
label: &str,
skip_variant: bool,
) -> View<'a, Message> {
if draft.kind == RecordKind::Trace && path == "/baseline" {
return history_fields::view(draft, project, value);
}
if let Some(view) = context_fields::property(draft, project, value, path) {
return view;
}
@@ -0,0 +1,666 @@
//! Typed editing of stored observations, independently of the live counterparts.
//! The checked-in, contract-tested OpenAPI descriptor supplies only field shapes.
//! Serde and core historical validation remain the admission authority.
use super::*;
use iced::widget::column;
use std::sync::OnceLock;
pub(super) const TOGGLE: &str = "/$stored_history";
pub(super) const PAGE: &str = "/$stored_history_page";
pub(super) const VARIANT: &str = "/$stored_history_variant";
const ROOT: &str = "/baseline";
const FACETS: &str = "/baseline/semantic/facets";
const PAGE_SIZE: usize = 12;
const MAX_DEPTH: usize = 32;
#[derive(Default)]
pub(super) struct State {
pub open: bool,
pub pages: BTreeMap<String, usize>,
}
pub(super) fn is_path(path: &str) -> bool {
path == ROOT || path.starts_with("/baseline/")
}
fn api() -> &'static Value {
static API: OnceLock<Value> = OnceLock::new();
API.get_or_init(|| {
serde_json::from_str(include_str!("../../../../docs/openapi.json"))
.expect("The bundled OpenAPI descriptor is valid JSON")
})
}
fn resolve(mut schema: &'static Value) -> Result<&'static Value, &'static str> {
for _ in 0..MAX_DEPTH {
let Some(reference) = schema.get("$ref").and_then(Value::as_str) else {
return Ok(schema);
};
schema = reference
.strip_prefix('#')
.and_then(|path| api().pointer(path))
.ok_or("Stored-history descriptor has an unresolved local reference.")?;
}
Err("Stored-history descriptor reference depth exceeded.")
}
fn named(name: &str) -> Result<&'static Value, &'static str> {
api()["components"]["schemas"]
.get(name)
.ok_or("Stored-history descriptor is missing a required type.")
}
fn alternatives(schema: &'static Value) -> Option<&'static Vec<Value>> {
schema
.get("oneOf")
.or_else(|| schema.get("anyOf"))
.and_then(Value::as_array)
}
fn at_schema(schema: &'static Value, parts: &[&str], depth: usize) -> Option<&'static Value> {
if depth > MAX_DEPTH {
return None;
}
let schema = resolve(schema).ok()?;
let Some((part, rest)) = parts.split_first() else {
return Some(schema);
};
if let Some(options) = alternatives(schema) {
return options.iter().find_map(|s| at_schema(s, parts, depth + 1));
}
match schema.get("type").and_then(Value::as_str) {
Some("object") => at_schema(schema.get("properties")?.get(*part)?, rest, depth + 1),
Some("array") if part.parse::<usize>().is_ok() => {
at_schema(schema.get("items")?, rest, depth + 1)
}
_ => None,
}
}
fn schema_at(path: &str) -> Option<&'static Value> {
let tail = path.strip_prefix(ROOT)?;
let parts: Vec<_> = tail
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
at_schema(named("TraceBaseline").ok()?, &parts, 0)
}
pub(super) fn has_field(path: &str) -> bool {
schema_at(path).is_some()
}
pub(super) fn free_string(path: &str) -> bool {
fn text_shape(schema: &'static Value, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let Ok(schema) = resolve(schema) else {
return false;
};
if let Some(options) = alternatives(schema) {
return options.iter().any(|s| text_shape(s, depth + 1));
}
schema.get("type").and_then(Value::as_str) == Some("string")
&& schema.get("enum").is_none()
&& schema.get("format").and_then(Value::as_str) != Some("uuid")
}
schema_at(path).is_some_and(|s| text_shape(s, 0))
}
/// A shape seed is a draft, never an acknowledged historical fact. UUIDs start
/// empty. Observed starts NA on both sides, which core rejects. Typed defaults
/// inside Value are introduced only after the engineer chooses that branch.
fn seed(schema: &'static Value, depth: usize) -> Result<Value, &'static str> {
if depth > MAX_DEPTH {
return Err("Stored-history constructor depth exceeded.");
}
let schema = resolve(schema)?;
if let Some(options) = alternatives(schema) {
if options
.iter()
.any(|s| matches_value(s, &json!("NotApplicable"), 0))
{
return Ok(json!("NotApplicable"));
}
if options.iter().any(|s| matches_value(s, &Value::Null, 0)) {
return Ok(Value::Null);
}
return seed(
options
.first()
.ok_or("Stored-history union has no constructors.")?,
depth + 1,
);
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
return values
.first()
.cloned()
.ok_or("Stored-history enumeration has no values.");
}
Ok(match schema.get("type").and_then(Value::as_str) {
Some("object") => {
let fields = schema
.get("properties")
.and_then(Value::as_object)
.ok_or("Stored-history object has no closed field descriptor.")?;
let mut result = serde_json::Map::new();
for (name, schema) in fields {
result.insert(name.clone(), seed(schema, depth + 1)?);
}
Value::Object(result)
}
Some("array") => json!([]),
Some("boolean") => json!(false),
Some("integer") => json!(0),
Some("number") => json!(0.0),
Some("string") => json!(""),
Some("null") => Value::Null,
_ => return Err("Unsupported stored-history field descriptor."),
})
}
/// Shape matching selects the displayed constructor; it is not validation.
/// Required values may be incomplete while their chosen branch stays editable.
fn matches_value(schema: &'static Value, value: &Value, depth: usize) -> bool {
if depth > MAX_DEPTH {
return false;
}
let Ok(schema) = resolve(schema) else {
return false;
};
if let Some(options) = alternatives(schema) {
return options.iter().any(|s| matches_value(s, value, depth + 1));
}
if let Some(choices) = schema.get("enum").and_then(Value::as_array) {
return choices.contains(value);
}
match schema.get("type").and_then(Value::as_str) {
Some("object") => value.as_object().is_some_and(|fields| {
schema
.get("properties")
.and_then(Value::as_object)
.is_some_and(|properties| {
fields.keys().all(|key| properties.contains_key(key))
&& schema
.get("required")
.and_then(Value::as_array)
.is_none_or(|required| {
required
.iter()
.filter_map(Value::as_str)
.all(|key| fields.contains_key(key))
})
})
}),
Some("array") => value.is_array(),
Some("boolean") => value.is_boolean(),
Some("integer" | "number") => value.is_number(),
Some("string") => value.is_string(),
Some("null") => value.is_null(),
_ => false,
}
}
fn label(name: &str) -> String {
match name {
"oa" => return "Operational observation".into(),
"sa" => return "System observation".into(),
"NotApplicable" => return "Not applicable".into(),
"Value" => return "Observed value".into(),
"keys" => return "Exact historical keys".into(),
"review" => return "Stored observations".into(),
_ => {}
}
let mut out = String::new();
let mut previous_lower = false;
for c in name.chars() {
if c == '_' {
out.push(' ');
} else {
if c.is_uppercase() && previous_lower {
out.push(' ');
}
out.push(c);
}
previous_lower = c.is_lowercase();
}
let mut chars = out.chars();
chars
.next()
.map(|c| c.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
}
fn branch_label(schema: &'static Value) -> String {
let Ok(schema) = resolve(schema) else {
return "Unavailable type".into();
};
if let Some(word) = schema
.get("enum")
.and_then(Value::as_array)
.filter(|v| v.len() == 1)
.and_then(|v| v[0].as_str())
{
return label(word);
}
if schema.get("enum").is_some() {
return "Enumeration · choose below".into();
}
if let Some(fields) = schema.get("properties").and_then(Value::as_object)
&& fields.len() == 1
{
return label(fields.keys().next().unwrap());
}
match schema.get("type").and_then(Value::as_str) {
Some("null") => "Absent (none)".into(),
Some("object") => "Set record".into(),
Some("array") => "Set list".into(),
Some("string") if schema.get("format").and_then(Value::as_str) == Some("uuid") => {
"Set historical UUID".into()
}
Some("string") => "Set text".into(),
Some("integer" | "number") => "Set number".into(),
_ => "Set value / choose constructor".into(),
}
}
#[derive(Clone, PartialEq, Eq)]
struct FactChoice {
label: String,
value: Value,
}
impl std::fmt::Display for FactChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
/// Flatten only the closed FacetReview selector levels, not observation values.
/// Collection and incident-end choices are selectors too, so none is guessed.
fn fact_choices() -> Result<&'static Vec<FactChoice>, &'static str> {
static CHOICES: OnceLock<Result<Vec<FactChoice>, &'static str>> = OnceLock::new();
CHOICES
.get_or_init(|| {
let root = resolve(named("FacetReview")?)?;
let mut result = vec![];
for branch in alternatives(root).ok_or("FacetReview must be a closed union.")? {
let outer = resolve(branch)?;
let (family, payload) = outer["properties"]
.as_object()
.and_then(|v| v.iter().next())
.ok_or("Facet family is missing.")?;
let payload = resolve(payload)?;
if let Some(leaves) = alternatives(payload) {
for leaf in leaves {
let mut value = serde_json::Map::new();
value.insert(family.clone(), seed(leaf, 0)?);
result.push(FactChoice {
label: format!("{} / {}", label(family), branch_label(leaf)),
value: Value::Object(value),
});
}
} else if family == "Artifact" {
let leaves = alternatives(resolve(&payload["properties"]["review"])?)
.ok_or("Artifact facts must be a closed union.")?;
for leaf in leaves {
let mut value = seed(branch, 0)?;
value[family]["review"] = seed(leaf, 0)?;
result.push(FactChoice {
label: format!("Artifact / {}", branch_label(leaf)),
value,
});
}
} else if matches!(family.as_str(), "Member" | "Order" | "Incident") {
let field = if family == "Incident" {
"end"
} else {
"collection"
};
let choices = resolve(&payload["properties"][field])?["enum"]
.as_array()
.ok_or("Historical selector choices are missing.")?;
for choice in choices {
let mut value = seed(branch, 0)?;
value[family][field] = choice.clone();
result.push(FactChoice {
label: format!(
"{} / {}",
label(family),
label(choice.as_str().unwrap_or(""))
),
value,
});
}
} else {
result.push(FactChoice {
label: label(family),
value: seed(branch, 0)?,
});
}
}
Ok(result)
})
.as_ref()
.map_err(|error| *error)
}
fn variant_message(path: &str, value: Value, present: bool) -> Message {
Message::FieldShape(
if present {
format!("{VARIANT}{path}")
} else {
path.into()
},
value,
)
}
fn branch_seed(schema: &'static Value) -> Result<Value, &'static str> {
let schema = resolve(schema)?;
if let Some(values) = schema
.get("enum")
.and_then(Value::as_array)
.filter(|v| v.len() == 1)
{
Ok(values[0].clone())
} else {
seed(schema, 0)
}
}
fn branch_has_fields(schema: &'static Value) -> bool {
let Ok(schema) = resolve(schema) else {
return false;
};
schema.get("type").and_then(Value::as_str) != Some("null")
&& schema
.get("enum")
.and_then(Value::as_array)
.is_none_or(|values| values.len() > 1)
}
fn paging<'a>(draft: &'a Draft, path: &str, count: usize) -> (usize, View<'a, Message>) {
let last = count.saturating_sub(1) / PAGE_SIZE;
let page = draft
.history
.pages
.get(path)
.copied()
.unwrap_or(0)
.min(last);
let event = |page| Message::FieldShape(format!("{PAGE}{path}"), json!(page));
let view = row![
button("Previous").on_press_maybe((page > 0).then(|| event(page - 1))),
text(format!(
"{}–{} of {count}",
if count == 0 { 0 } else { page * PAGE_SIZE + 1 },
((page + 1) * PAGE_SIZE).min(count)
))
.size(11),
button("Next").on_press_maybe((page < last).then(|| event(page + 1)))
]
.spacing(6)
.align_y(iced::Center)
.into();
(page, view)
}
fn render<'a>(
draft: &'a Draft,
project: &'a Project,
schema: &'static Value,
value: Option<&'a Value>,
path: &str,
title: &str,
depth: usize,
) -> View<'a, Message> {
if depth > MAX_DEPTH {
return text("Stored-history field nesting exceeds the supported editor depth.")
.size(11)
.into();
}
let schema = match resolve(schema) {
Ok(s) => s,
Err(e) => return text(e).size(11).into(),
};
let present = value.is_some();
let value = value.unwrap_or(&Value::Null);
if let Some(options) = alternatives(schema) {
let initial: Vec<_> = match options.iter().map(branch_seed).collect::<Result<_, _>>() {
Ok(values) => values,
Err(error) => {
return text(format!(
"Stored-history constructor is unavailable: {error}"
))
.size(11)
.into();
}
};
let labels: Vec<_> = options.iter().map(branch_label).collect();
let selected = options.iter().position(|s| matches_value(s, value, 0));
let path_owned = path.to_string();
let labels_owned = labels.clone();
let mut out = column![
text(title.to_owned()).size(11).color(style::MUTED),
pick_list(
labels,
selected.map(|i| branch_label(&options[i])),
move |choice| {
let index = labels_owned.iter().position(|v| v == &choice).unwrap();
variant_message(&path_owned, initial[index].clone(), present)
}
)
.placeholder("Choose stored value / constructor…")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(6);
if let Some(index) = selected {
let branch = resolve(&options[index]).unwrap_or(&options[index]);
if branch_has_fields(branch) {
out = out.push(render(
draft,
project,
branch,
Some(value),
path,
"",
depth + 1,
));
}
}
return out.into();
}
if let Some(words) = schema.get("enum").and_then(Value::as_array) {
let choices: Vec<_> = words
.iter()
.filter_map(Value::as_str)
.map(str::to_owned)
.collect();
let selected = value
.as_str()
.map(str::to_owned)
.filter(|v| choices.contains(v));
let path = path.to_string();
return column![
text(title.to_owned()).size(11).color(style::MUTED),
pick_list(choices, selected, move |word| Message::FieldShape(
path.clone(),
json!(word)
))
.placeholder("Choose an explicit value…")
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(6)
.into();
}
match schema.get("type").and_then(Value::as_str) {
Some("object") => {
let Some(fields) = schema.get("properties").and_then(Value::as_object) else {
return text("Unsupported stored-history object shape.").size(11).into();
};
let mut out = column![].spacing(8);
if !title.is_empty() { out = out.push(text(title.to_owned()).size(12).color(style::INK)); }
for (key, child_schema) in fields {
out = out.push(render(draft, project, child_schema, value.get(key), &format!("{path}/{key}"), &label(key), depth + 1));
}
out.into()
}
Some("array") => {
let Some(items) = value.as_array() else {
return button("Initialize empty list").on_press(Message::FieldShape(path.into(), json!([]))).into();
};
let Some(item_schema) = schema.get("items") else { return text("Missing history list item type.").into(); };
let mut out = column![text(title.to_owned()).size(12)].spacing(8);
if path == FACETS {
match fact_choices() {
Ok(choices) => {
let path = path.to_owned();
out = out.push(pick_list(choices.clone(), None::<FactChoice>, move |choice| Message::FieldAppend(path.clone(), choice.value))
.placeholder("Add: choose family and fact…").text_size(12).padding(8).width(Fill));
}
Err(error) => { out = out.push(text(error).size(11)); }
}
} else {
match seed(item_schema, 0) {
Ok(initial) => out = out.push(button("Add list value").on_press(Message::FieldAppend(path.into(), initial))),
Err(error) => out = out.push(text(error).size(11)),
}
}
let (page, pager) = paging(draft, path, items.len());
if items.len() > PAGE_SIZE { out = out.push(pager); }
for (index, item) in items.iter().enumerate().skip(page * PAGE_SIZE).take(PAGE_SIZE) {
let item_path = format!("{path}/{index}");
let mut card = column![render(draft, project, item_schema, Some(item), &item_path, &format!("{} {}", title, index + 1), depth + 1)].spacing(8);
if path == FACETS {
if let Some(current) = current_observation(draft, project, &item_path) {
card = card.push(button("Use current counterpart observations")
.on_press(Message::FieldShape(item_path.clone(), json!(current))).style(style::nav));
}
}
card = card.push(row![
button("↑").on_press_maybe((index > 0).then(|| Message::FieldArray(path.into(), Some(index), -1))),
button("↓").on_press_maybe((index + 1 < items.len()).then(|| Message::FieldArray(path.into(), Some(index), 1))),
button("Remove").on_press(Message::FieldArray(path.into(), Some(index), 0))
].spacing(6));
out = out.push(container(card).padding(10).style(style::subtle));
}
if items.is_empty() { out = out.push(text("Empty list · no stored entries").size(11).color(style::MUTED)); }
out.into()
}
Some("boolean") => {
let path = path.to_owned();
checkbox(value.as_bool().unwrap_or(false)).label(title.to_owned())
.on_toggle(move |v| Message::FieldShape(path.clone(), json!(v))).text_size(12).into()
}
Some("integer" | "number" | "string") => {
let is_uuid = schema.get("format").and_then(Value::as_str) == Some("uuid");
let numeric = schema.get("type").and_then(Value::as_str) != Some("string");
let mut out = column![text(title.to_owned()).size(11).color(style::MUTED)].spacing(5);
if let Some(input) = draft.inputs.get(path) {
let owned = path.to_owned();
if !numeric && !is_uuid && !draft.exact_strings {
out = out.push(text_editor(&input.editor).on_action(move |a| Message::FieldText(owned.clone(), a)).size(12).height(72).padding(8));
} else {
out = out.push(text_input(if is_uuid { "Historical UUID, including deleted identities" } else { "Stored value" }, &input.raw)
.on_input(move |raw| Message::Field(owned.clone(), raw)).size(12).padding(8));
}
} else {
match seed(schema, 0) {
Ok(value) => out = out.push(button("Set stored value").on_press(Message::FieldShape(path.into(), value))),
Err(error) => out = out.push(text(error).size(11)),
}
}
if is_uuid { out = out.push(text("Historical identity · existence in the current model is not required.").size(10).color(style::MUTED)); }
out.into()
}
Some("null") => text("Absent (none)").size(11).into(),
_ => text("Unsupported stored-history descriptor. The draft is retained; this field cannot be edited with these controls.").size(11).into(),
}
}
fn current_observation(draft: &Draft, project: &Project, item_path: &str) -> Option<FacetReview> {
let raw = draft
.materialize_scopes(&[item_path.to_owned(), "/oa".into(), "/sa".into()])
.ok()?;
let fact: FacetReview = serde_json::from_value(raw.pointer(item_path)?.clone()).ok()?;
let oa = raw.get("oa")?.as_str()?.parse().ok()?;
let sa = raw.get("sa")?.as_str()?.parse().ok()?;
semantic::capture_facet(project, oa, sa, &fact.selector()).ok()
}
pub(super) fn view<'a>(
draft: &'a Draft,
project: &'a Project,
value: &'a Value,
) -> View<'a, Message> {
let count = value
.pointer("/semantic/facets")
.and_then(Value::as_array)
.map_or(0, Vec::len);
let mut out = column![
text("Stored review history").size(12).color(style::INK),
text(if value.is_null() {
"No stored baseline".into()
} else {
format!("Prose baseline · {count} semantic observations")
})
.size(11)
.color(style::MUTED),
button(if draft.history.open {
"Close stored-history editor"
} else {
"Edit stored history (advanced)…"
})
.on_press(Message::FieldShape(
TOGGLE.into(),
json!(!draft.history.open)
))
.style(style::nav)
]
.spacing(8);
if !draft.history.open {
return out.into();
}
out = out.push(text("These edits change the trace's stored observations only. They do not change either counterpart. Ordinary Keep/Take review remains available from Traceability.").size(11));
if value.is_null() {
return match named("TraceBaseline").and_then(|s| seed(s, 0)) {
Ok(initial) => out
.push(
button("Start empty stored baseline")
.on_press(Message::FieldShape(ROOT.into(), initial)),
)
.into(),
Err(error) => out.push(text(error).size(11)).into(),
};
}
out = out.push(
button("Clear whole stored baseline")
.on_press(Message::FieldShape(ROOT.into(), Value::Null))
.style(style::nav),
);
match named("TraceBaseline") {
Ok(schema) => out = out.push(render(draft, project, schema, Some(value), ROOT, "", 0)),
Err(error) => out = out.push(text(error).size(11)),
}
let checked = draft.materialize_scopes(&[ROOT.into()]).and_then(|raw| {
let baseline: TraceBaseline =
serde_json::from_value(raw["baseline"].clone()).map_err(|e| e.to_string())?;
baseline.semantic.as_ref().map_or(Ok(()), |b| {
semantic::validate_semantic_baseline(b).map_err(|e| e.join("; "))
})
});
if let Err(error) = checked {
out = out.push(text(format!("Stored history is incomplete or invalid: {error}")).size(11));
}
out.into()
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,733 @@
use super::*;
use crate::document::Document;
fn fixture(baseline: Option<TraceBaseline>) -> (Project, Id) {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Assess need", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Assess request", None)
.unwrap();
let id = Id::from_u128(9001);
project.traces.insert(
id,
Trace {
id,
alias: "assessment_review".into(),
name: "Assessment correspondence".into(),
description: "Engineering review history".into(),
oa,
sa,
kind: TraceKind::Refinement,
baseline,
},
);
validate_candidate(&project).unwrap();
(project, id)
}
fn baseline(facets: Vec<FacetReview>) -> TraceBaseline {
TraceBaseline {
oa: TracedProperties {
name: "Earlier OA name".into(),
description: "OA note".into(),
},
sa: TracedProperties {
name: "Earlier SA name".into(),
description: "SA note".into(),
},
semantic: Some(SemanticBaseline { facets }),
}
}
fn document(project: &Project) -> Document {
let mut bundle = source_project::format_bundle(project);
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.insert_str(0, "// Retain this authored project note.\n\n");
Document::from_bundle(bundle, Default::default()).unwrap()
}
fn choice(selector: FacetSelector) -> Value {
fact_choices()
.unwrap()
.iter()
.find(|choice| {
serde_json::from_value::<FacetReview>(choice.value.clone())
.unwrap()
.selector()
== selector
})
.unwrap()
.value
.clone()
}
fn add_header(draft: &mut Draft) {
draft
.append(FACETS, choice(FacetSelector::Header(HeaderFacet::Name)))
.unwrap();
}
fn assert_history_transaction(document: &mut Document, draft: &Draft, expected: &Project) {
let before = document.project().clone();
let source_before = document.bundle().clone();
document.execute(draft.command().unwrap()).unwrap();
assert_eq!(document.project(), expected);
let accepted_source = document.bundle().clone();
assert!(
accepted_source.files[&accepted_source.entry]
.starts_with("// Retain this authored project note.\n\n")
);
assert_eq!(
Document::from_bundle(accepted_source.clone(), Default::default())
.unwrap()
.project(),
expected
);
assert_eq!(
serde_json::from_slice::<Project>(&serde_json::to_vec(expected).unwrap()).unwrap(),
*expected
);
assert!(document.undo());
assert_eq!(document.project(), &before);
assert_eq!(document.bundle(), &source_before);
assert!(
!document.undo(),
"one stored-history Apply must create one history entry"
);
assert!(document.redo());
assert_eq!(document.project(), expected);
assert_eq!(document.bundle(), &accepted_source);
assert!(!document.redo());
}
#[test]
fn history_absent_baseline_and_empty_history_use_explicit_facts_and_exact_document_history() {
for initial in [None, Some(baseline(vec![]))] {
let (project, id) = fixture(initial);
let mut doc = document(&project);
let mut draft = Draft::from_project(&project, id).unwrap();
assert!(!draft.history.open);
draft.shape(TOGGLE, json!(true)).unwrap();
assert!(draft.history.open);
assert!(
!draft.dirty(),
"opening the advanced editor cannot acknowledge history"
);
if draft.value["baseline"].is_null() {
draft
.shape(ROOT, seed(named("TraceBaseline").unwrap(), 0).unwrap())
.unwrap();
draft
.shape("/baseline/semantic", json!({"facets":[]}))
.unwrap();
}
let untouched = draft.value.clone();
assert!(
draft
.array(FACETS, None, 0)
.unwrap_err()
.contains("Choose a typed")
);
assert_eq!(
draft.value, untouched,
"the old nil-UUID Add must not mutate the draft"
);
add_header(&mut draft);
let before = doc.snapshot();
assert!(
doc.execute(draft.command().unwrap()).is_err(),
"NA/NA is incomplete, not a reviewed default"
);
assert!(std::sync::Arc::ptr_eq(&before, &doc.snapshot()));
let current = current_observation(&draft, &project, &format!("{FACETS}/0")).unwrap();
assert_eq!(
current,
semantic::capture_facet(
&project,
project.traces[&id].oa,
project.traces[&id].sa,
&FacetSelector::Header(HeaderFacet::Name)
)
.unwrap()
);
draft.shape(&format!("{FACETS}/0"), json!(current)).unwrap();
let mut expected = project.clone();
expected.traces.get_mut(&id).unwrap().baseline =
Some(serde_json::from_value(draft.materialize().unwrap()["baseline"].clone()).unwrap());
assert_history_transaction(&mut doc, &draft, &expected);
}
}
#[test]
fn history_missing_semantic_field_can_be_added_and_removed_without_touching_prose_or_live_objects()
{
let mut prose = baseline(vec![]);
prose.semantic = None;
let (project, id) = fixture(Some(prose));
let mut doc = document(&project);
let mut draft = Draft::from_project(&project, id).unwrap();
assert!(draft.value.pointer("/baseline/semantic").is_none());
draft
.shape("/baseline/semantic", json!({"facets":[]}))
.unwrap();
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline::default());
assert_history_transaction(&mut doc, &draft, &expected);
let mut restored = Draft::from_project(&expected, id).unwrap();
restored.shape("/baseline/semantic", Value::Null).unwrap();
assert_eq!(
execute_batch(&expected, &[restored.command().unwrap()]).unwrap(),
project
);
}
/// Generate type witnesses independently of the editor's deliberately incomplete
/// seeds, then ask the real closed Rust types and historical validator to admit them.
fn witness(schema: &'static Value, active: &Value, depth: usize) -> Value {
assert!(
depth <= MAX_DEPTH,
"recursive/unsupported historical descriptor"
);
let schema = resolve(schema).unwrap();
if let Some(options) = alternatives(schema) {
let selected = if !active.is_null() && active != "NotApplicable" {
options.iter().find(|s| matches_value(s, active, 0))
} else {
None
};
let selected = selected
.or_else(|| {
options.iter().find(|s| {
!matches_value(s, &Value::Null, 0)
&& !matches_value(s, &json!("NotApplicable"), 0)
})
})
.unwrap_or(&options[0]);
return witness(selected, active, depth + 1);
}
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
return if values.contains(active) {
active.clone()
} else {
values[0].clone()
};
}
match schema["type"].as_str().unwrap() {
"object" => schema["properties"]
.as_object()
.unwrap()
.iter()
.map(|(key, s)| {
(
key.clone(),
witness(s, active.get(key).unwrap_or(&Value::Null), depth + 1),
)
})
.collect::<serde_json::Map<_, _>>()
.into(),
"array" => json!([witness(&schema["items"], &Value::Null, depth + 1)]),
"string" if schema["format"] == "uuid" => json!(Id::from_u128(777)),
"string" => json!("Earlier Ω value\nwith provenance"),
"integer" => json!(1),
"number" => json!(1.25),
"boolean" => json!(true),
"null" => Value::Null,
other => panic!("unsupported historical schema {other}"),
}
}
#[test]
fn history_every_descriptor_fact_choice_matches_actual_closed_types_and_source_contract() {
let (project, id) = fixture(Some(baseline(vec![])));
let mut selectors = BTreeSet::new();
let choices = fact_choices().unwrap();
assert!(
choices.len() > 100,
"the full scalar and keyed catalog must be reachable"
);
for entry in choices {
let incomplete: FacetReview = serde_json::from_value(entry.value.clone()).unwrap();
assert!(
selectors.insert(incomplete.selector()),
"duplicate Add selector: {}",
entry.label
);
assert!(
semantic::validate_semantic_baseline(&SemanticBaseline {
facets: vec![incomplete]
})
.is_err()
);
let complete = witness(named("FacetReview").unwrap(), &entry.value, 0);
let typed: FacetReview = serde_json::from_value(complete.clone())
.unwrap_or_else(|e| panic!("{}: {e}", entry.label));
semantic::validate_semantic_baseline(&SemanticBaseline {
facets: vec![typed.clone()],
})
.unwrap_or_else(|e| panic!("{}: {e:?}", entry.label));
let mut draft = Draft::from_project(&project, id).unwrap();
draft.append(FACETS, complete).unwrap();
draft.history.open = true;
let _controls = view(&draft, &project, &draft.value["baseline"]);
let result = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
let parsed = source_project::compile_bundle(
&source_project::format_bundle(&result),
Default::default(),
)
.unwrap();
assert_eq!(
parsed.decoded.project, result,
"source parity for {}",
entry.label
);
assert_eq!(
result.traces[&id]
.baseline
.as_ref()
.unwrap()
.semantic
.as_ref()
.unwrap()
.facets,
vec![typed]
);
}
}
#[test]
fn history_deleted_keys_and_observation_uuids_remain_exact_and_duplicate_history_rejects() {
let old_link = Id::from_u128(401);
let old_owner = Id::from_u128(402);
let fact = FacetReview::Artifact {
keys: KeyPair {
oa: Some(old_link),
sa: None,
},
review: ArtifactReview::Owner(Reviewed {
oa: Observed::Value(old_owner),
sa: Observed::NotApplicable,
}),
};
let (project, id) = fixture(Some(baseline(vec![fact])));
assert!(!project.contains(old_link) && !project.contains(old_owner));
let mut doc = document(&project);
let mut draft = Draft::from_project(&project, id).unwrap();
let key_path = format!("{FACETS}/0/Artifact/keys/oa");
let owner_path = format!("{FACETS}/0/Artifact/review/Owner/oa/Value");
assert!(!free_string(&key_path) && !free_string(&owner_path));
let edited_key = Id::from_u128(403);
let edited_owner = Id::from_u128(404);
draft.input(&key_path, edited_key.to_string());
draft.input(&owner_path, "bad uuid".into());
assert!(draft.command().is_err());
draft.input(&owner_path, edited_owner.to_string());
let accepted = draft
.materialize()
.unwrap()
.pointer(&format!("{FACETS}/0"))
.unwrap()
.clone();
draft.append(FACETS, accepted.clone()).unwrap();
let before = doc.snapshot();
assert!(
doc.execute(draft.command().unwrap()).is_err(),
"overlapping historic addresses must reject"
);
assert!(std::sync::Arc::ptr_eq(&before, &doc.snapshot()));
draft.array(FACETS, Some(1), 0).unwrap();
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic
.as_mut()
.unwrap()
.facets = vec![serde_json::from_value(accepted).unwrap()];
assert_history_transaction(&mut doc, &draft, &expected);
}
#[test]
fn history_invalid_numbers_survive_constructor_switch_reorder_removal_and_recovery() {
let (project, id) = fixture(Some(baseline(vec![])));
let mut draft = Draft::from_project(&project, id).unwrap();
draft
.append(
FACETS,
choice(FacetSelector::Data(DataFacet::IntegerMinimum)),
)
.unwrap();
let side = format!("{FACETS}/0/Data/IntegerMinimum/oa");
draft
.shape(&format!("{VARIANT}{side}"), json!({"Value": 7}))
.unwrap();
draft.input(&format!("{side}/Value"), "-".into());
add_header(&mut draft);
assert_eq!(draft.inputs[&format!("{side}/Value")].raw, "-");
draft
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
.unwrap();
draft.array(FACETS, Some(0), 1).unwrap();
let moved_side = format!("{FACETS}/1/Data/IntegerMinimum/oa");
draft
.shape(&format!("{VARIANT}{moved_side}"), json!({"Value": 0}))
.unwrap();
assert_eq!(
draft.inputs[&format!("{moved_side}/Value")].raw,
"-",
"inactive constructor drafts follow the historical row"
);
let saved = draft.recovery_snapshot();
let mut recovered = Draft::from_recovery(&project, saved).unwrap();
assert!(recovered.history.open);
assert_eq!(recovered.inputs[&format!("{moved_side}/Value")].raw, "-");
assert!(recovered.command().is_err());
recovered.array(FACETS, Some(0), 0).unwrap();
assert_eq!(recovered.inputs[&format!("{side}/Value")].raw, "-");
recovered.input(&format!("{side}/Value"), i64::MIN.to_string());
let mut doc = document(&project);
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: vec![FacetReview::Data(DataReview::IntegerMinimum(Reviewed {
oa: Observed::Value(Some(i64::MIN)),
sa: Observed::NotApplicable,
}))],
});
assert_history_transaction(&mut doc, &recovered, &expected);
}
#[test]
fn history_compound_value_and_constructor_variants_are_editable_without_live_context() {
let (project, id) = fixture(Some(baseline(vec![])));
let old = Id::from_u128(700);
let values = vec![
FacetReview::Data(DataReview::Default(Reviewed {
oa: Observed::Value(Some(DataValue::Boolean(true))),
sa: Observed::NotApplicable,
})),
FacetReview::Field(FieldReview::Type(Reviewed {
oa: Observed::Value(TypeRef::Named(old)),
sa: Observed::Value(TypeRef::Real),
})),
FacetReview::Occurrence(OccurrenceReview::Operation(Reviewed {
oa: Observed::NotApplicable,
sa: Observed::Value(Some(OperationUse {
allocation: Some(old),
})),
})),
FacetReview::Relation(RelationReview::ContextSource(Reviewed {
oa: Observed::Value(OccurrenceEndpoint {
references: vec![old, Id::from_u128(701)],
occurrence: Id::from_u128(702),
}),
sa: Observed::NotApplicable,
})),
FacetReview::Relation(RelationReview::ContextPurpose(Reviewed {
oa: Observed::Value(ContextualLinkPurpose::CommonWork { work: old }),
sa: Observed::NotApplicable,
})),
FacetReview::Annotation {
keys: KeyPair {
oa: Some(AnnotationKey {
path: vec![old],
field: old,
}),
sa: None,
},
review: Reviewed {
oa: Observed::Value(Some(MessageValue {
path: vec![old],
field: old,
expression: "verbatim\tΩ".into(),
})),
sa: Observed::NotApplicable,
},
},
FacetReview::Binding {
keys: KeyPair {
oa: Some(old),
sa: None,
},
review: Reviewed {
oa: Observed::Value(Some(LifelineBinding {
referenced: old,
local: Id::from_u128(702),
})),
sa: Observed::NotApplicable,
},
},
];
let mut draft = Draft::from_project(&project, id).unwrap();
for value in &values {
draft.append(FACETS, json!(value)).unwrap();
}
let literal = format!("{FACETS}/0/Data/Default/oa/Value");
for value in [
DataValue::Integer(i64::MAX),
DataValue::Real(-0.25),
DataValue::String("quoted\r\nΩ".into()),
DataValue::Enumeration("Literal".into()),
] {
draft
.shape(&format!("{VARIANT}{literal}"), json!(value))
.unwrap();
let model = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
assert!(
matches!(&model.traces[&id].baseline.as_ref().unwrap().semantic.as_ref().unwrap().facets[0], FacetReview::Data(DataReview::Default(Reviewed { oa: Observed::Value(Some(actual)), .. })) if actual == &value)
);
}
let purpose = format!("{FACETS}/4/Relation/ContextPurpose/oa/Value");
draft
.shape(
&format!("{VARIANT}{purpose}"),
json!({"Precedence":{"guard":"reviewed before dispatch"}}),
)
.unwrap();
let mut doc = document(&project);
let mut expected = project.clone();
expected.traces.get_mut(&id).unwrap().baseline =
Some(serde_json::from_value(draft.materialize().unwrap()["baseline"].clone()).unwrap());
assert_history_transaction(&mut doc, &draft, &expected);
}
#[test]
fn history_exact_strings_do_not_confuse_uuid_shaped_prose_or_literal_keys_with_references() {
let (project, id) = fixture(Some(baseline(vec![])));
let mut draft = Draft::from_project(&project, id).unwrap();
add_header(&mut draft);
let side = format!("{FACETS}/0/Header/Name/oa");
draft
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
.unwrap();
let path = format!("{side}/Value");
assert!(free_string(&path));
draft.set_exact(true).unwrap();
let value = "00000000-0000-0000-0000-000000000077\tΩ\r\nname";
draft.input(&path, serde_json::to_string(value).unwrap());
draft
.append(
FACETS,
choice(FacetSelector::Literal(KeyPair { oa: None, sa: None })),
)
.unwrap();
let key = format!("{FACETS}/1/Literal/keys/oa");
draft
.shape(&format!("{VARIANT}{key}"), json!("literal"))
.unwrap();
draft.input(&key, serde_json::to_string("literal\tΩ").unwrap());
draft
.shape(
&format!("{VARIANT}{FACETS}/1/Literal/review/oa"),
json!({"Value":true}),
)
.unwrap();
assert!(free_string(&key));
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: vec![
FacetReview::Header(HeaderReview::Name(Reviewed {
oa: Observed::Value(value.into()),
sa: Observed::NotApplicable,
})),
FacetReview::Literal {
keys: KeyPair {
oa: Some("literal\tΩ".into()),
sa: None,
},
review: Reviewed {
oa: Observed::Value(true),
sa: Observed::NotApplicable,
},
},
],
});
assert_history_transaction(&mut document(&project), &draft, &expected);
}
#[test]
fn history_primitive_type_choices_remain_visible_inside_the_tagged_union() {
let schema = resolve(named("TypeRef").unwrap()).unwrap();
let branches = alternatives(schema).unwrap();
let primitives = branches
.iter()
.find(|branch| {
resolve(branch)
.unwrap()
.get("enum")
.and_then(Value::as_array)
.is_some_and(|values| values.len() > 1)
})
.unwrap();
assert!(
branch_has_fields(primitives),
"the primitive branch needs an inner picker, not only its outer union label"
);
let (project, id) = fixture(Some(baseline(vec![])));
let mut draft = Draft::from_project(&project, id).unwrap();
draft
.append(
FACETS,
json!(FacetReview::Field(FieldReview::Type(Reviewed {
oa: Observed::Value(TypeRef::Named(Id::from_u128(888))),
sa: Observed::NotApplicable,
}))),
)
.unwrap();
let path = format!("{FACETS}/0/Field/Type/oa/Value");
draft
.shape(
&format!("{VARIANT}{path}"),
branch_seed(primitives).unwrap(),
)
.unwrap();
for value in [
TypeRef::Boolean,
TypeRef::Integer,
TypeRef::Real,
TypeRef::String,
] {
assert!(matches_value(primitives, &json!(value), 0));
draft.shape(&path, json!(value)).unwrap();
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: vec![FacetReview::Field(FieldReview::Type(Reviewed {
oa: Observed::Value(value),
sa: Observed::NotApplicable,
}))],
});
assert_history_transaction(&mut document(&project), &draft, &expected);
}
}
#[test]
fn history_retained_text_constructors_follow_exact_mode_without_changing_the_observation() {
let (project, id) = fixture(Some(baseline(vec![])));
let mut draft = Draft::from_project(&project, id).unwrap();
add_header(&mut draft);
let side = format!("{FACETS}/0/Header/Name/oa");
let path = format!("{side}/Value");
let stored = "Earlier \"quoted\" name\tΩ\r\nsecond line";
draft
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
.unwrap();
draft.input(&path, stored.into());
draft
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
.unwrap();
draft.set_exact(true).unwrap();
draft
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
.unwrap();
assert_eq!(
draft.inputs[&path].raw,
serde_json::to_string(stored).unwrap()
);
draft
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
.unwrap();
draft.set_exact(false).unwrap();
draft
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
.unwrap();
assert_eq!(draft.inputs[&path].raw, stored);
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: vec![FacetReview::Header(HeaderReview::Name(Reviewed {
oa: Observed::Value(stored.into()),
sa: Observed::NotApplicable,
}))],
});
assert_history_transaction(&mut document(&project), &draft, &expected);
}
#[test]
fn history_clear_discards_inactive_invalid_constructors_before_reusing_the_same_row() {
let (project, id) = fixture(Some(baseline(vec![])));
let mut draft = Draft::from_project(&project, id).unwrap();
add_header(&mut draft);
let side = format!("{FACETS}/0/Header/Name/oa");
let path = format!("{side}/Value");
draft
.shape(&format!("{VARIANT}{side}"), json!({"Value":""}))
.unwrap();
draft.set_exact(true).unwrap();
draft.input(&path, "\"unfinished".into());
draft
.shape(&format!("{VARIANT}{side}"), json!("NotApplicable"))
.unwrap();
assert!(
draft.set_exact(false).is_err(),
"invalid retained quoted text cannot silently change modes"
);
assert!(draft.exact_strings);
draft.shape(ROOT, Value::Null).unwrap();
assert!(draft.constructor_drafts.is_empty());
draft.set_exact(false).unwrap();
draft.shape(ROOT, json!(baseline(vec![]))).unwrap();
add_header(&mut draft);
draft
.shape(
&format!("{VARIANT}{side}"),
json!({"Value":"Fresh observation"}),
)
.unwrap();
assert_eq!(draft.inputs[&path].raw, "Fresh observation");
let mut expected = project.clone();
expected
.traces
.get_mut(&id)
.unwrap()
.baseline
.as_mut()
.unwrap()
.semantic = Some(SemanticBaseline {
facets: vec![FacetReview::Header(HeaderReview::Name(Reviewed {
oa: Observed::Value("Fresh observation".into()),
sa: Observed::NotApplicable,
}))],
});
assert_history_transaction(&mut document(&project), &draft, &expected);
}
+1 -1
View File
@@ -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",