Fix exchange-only process continuation with explicit append path

This commit is contained in:
2026-09-06 05:42:01 +03:00
parent dfc14bcd1f
commit 8bc564b4b2
4 changed files with 268 additions and 3 deletions
+120
View File
@@ -143,6 +143,18 @@ impl App {
Err(e) => self.status = e,
}
}
CanvasEvent::AppendExchange(_) => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.append_exchange(self.project(), context.owner);
self.behavior.topology = state;
match result {
Ok(()) => {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.status = "Choose the next exchange; Create appends it with an explicit sequence link.".into();
}
Err(e) => self.status = e,
}
}
CanvasEvent::Connect { source, target, .. } => {
let mut state = std::mem::take(&mut self.behavior.topology);
let result = state.connect(self.project(), context.owner, source, target);
@@ -222,6 +234,114 @@ mod tests {
}
}
#[test]
fn direct_process_append_empty_first_second_exchange_has_exact_source_history() {
for layer in [Layer::Oa, Layer::Sa] {
let (f, next) = direct_tests::empty_exchange_fixture(layer);
let mut app = App::with_project(f.p.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(Page::Behavior));
let empty = app.document.snapshot();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Begin {
context,
tool: Tool::Exchange,
control: ControlKind::Choice,
insertion: Insertion::default(),
}));
assert!(app.behavior.has_pending());
form(
&mut app,
Event::Target(Choice {
id: f.exchange,
label: "Assessment".into(),
}),
);
form(&mut app, Event::Name("First contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let first_id = app.selected;
let first = app.document.snapshot();
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id]
);
assert_eq!(app.project().relations, empty.decoded.project.relations);
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::AppendExchange(
context,
)));
assert!(
app.behavior.has_pending(),
"reachable single-exchange continuation: {}",
app.status
);
assert!(Arc::ptr_eq(&first, &app.document.snapshot()));
form(
&mut app,
Event::Target(Choice {
id: next,
label: "Response".into(),
}),
);
form(&mut app, Event::Name("Second contribution".into()));
form(&mut app, Event::Submit);
assert!(!app.behavior.has_pending(), "{}", app.status);
let second_id = app.selected;
let second = app.document.snapshot();
assert_ne!(first_id, second_id);
assert_eq!(
app.project().elements[&f.owner].steps().unwrap(),
&[first_id, second_id]
);
let links = app
.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::SequenceLink)
.collect::<Vec<_>>();
assert_eq!(links.len(), 1);
assert_eq!(
(links[0].source(), links[0].target()),
(first_id, second_id)
);
assert_eq!(
app.project().elements.len(),
empty.decoded.project.elements.len() + 2
);
for (id, relation) in &empty.decoded.project.relations {
assert_eq!(app.project().relations[id], *relation);
}
for work in f.work {
assert_eq!(
app.project().elements[&work],
empty.decoded.project.elements[&work]
);
}
let reopened = Document::from_bundle(
second.bundle.clone(),
oa_core::source_project::ProjectLimits::default(),
)
.unwrap();
assert_eq!(reopened.project(), app.project());
assert_eq!(reopened.bundle(), &second.bundle);
for expected in [&first, &empty] {
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(
!app.document.can_undo(),
"each Create must add exactly one history entry"
);
for expected in [&first, &second] {
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.project(), &expected.decoded.project);
}
assert!(!app.document.can_redo());
}
}
#[test]
fn direct_process_app_stale_context_dirty_source_and_properties_never_mutate() {
let (mut app, _) = setup();
let snapshot = app.document.snapshot();
@@ -30,6 +30,7 @@ pub enum CanvasEvent {
Scrolled(Context, scenario::ScrollArea, iced::Vector),
Choose(Context, CanvasTool),
Select(Context, Id),
AppendExchange(Context),
Begin {
context: Context,
tool: Tool,
@@ -67,6 +68,7 @@ impl CanvasEvent {
| Self::Expand(c, ..)
| Self::Open(c, ..)
| Self::Back(c)
| Self::AppendExchange(c)
| Self::Zoom(c, ..)
| Self::Viewport(c, ..) => *c,
Self::Begin { context, .. }
@@ -204,6 +206,15 @@ impl State {
}
Ok(())
}
pub(crate) fn append_exchange(&mut self, p: &Project, owner: Id) -> Result<(), String> {
let last = legacy_order(p, owner)
.last()
.copied()
.ok_or("Choose an ordered exchange path to append to.")?;
self.update(p, Event::Start(owner, Tool::Exchange, Some(last)))?;
self.draft.as_mut().expect("started form").fixed = true;
Ok(())
}
pub(crate) fn connect(
&mut self,
p: &Project,
@@ -848,3 +848,123 @@ fn direct_process_real_picker_stays_bounded_and_search_keeps_selection() {
assert_eq!(state.draft.as_ref().unwrap().target, Some(f.work[0]));
assert_eq!(state.draft.as_ref().unwrap().browsers[0].page, 0);
}
pub(crate) fn empty_exchange_fixture(layer: Layer) -> (Fixture, Id) {
let mut f = fixture(layer);
let mut commands = f.nodes.into_iter().map(Command::Delete).collect::<Vec<_>>();
commands.push(Command::Connect(
f.p.relations[&f.exchange].kind(),
"Response".into(),
f.work[1],
f.work[2],
));
f.p = execute_batch(&f.p, &commands).unwrap();
let next =
f.p.relations
.values()
.find(|r| r.name == "Response")
.unwrap()
.id;
assert!(f.p.elements[&f.owner].steps().unwrap().is_empty());
(f, next)
}
#[test]
fn direct_process_append_preserves_existing_order_and_rejects_incompatible_exchange() {
for layer in [Layer::Oa, Layer::Sa] {
let (mut f, next) = empty_exchange_fixture(layer);
let kind = f.p.relations[&f.exchange].kind();
f.p = execute_batch(
&f.p,
&[Command::Connect(
kind,
"Recovery".into(),
f.work[2],
f.work[0],
)],
)
.unwrap();
let final_exchange =
f.p.relations
.values()
.find(|r| r.name == "Recovery")
.unwrap()
.id;
for exchange in [f.exchange, next] {
let e = occurrence(
&f.p,
f.owner,
"Ordered contribution",
OccurrenceBody::Exchange {
exchange: Some(exchange),
sender: None,
receiver: None,
message_kind: MessageKind::default(),
reply_to: None,
values: vec![],
},
);
f.p = execute_batch(&f.p, &[Command::CreateElement(e)]).unwrap();
}
let before = f.p.elements[&f.owner].steps().unwrap().clone();
assert_eq!(legacy_order(&f.p, f.owner), before);
assert!(sequence_links(&f.p, f.owner).is_empty());
let mut state = State::default();
state.append_exchange(&f.p, f.owner).unwrap();
let draft = state.draft.as_ref().unwrap();
assert!(draft.fixed);
assert_eq!(draft.before, before.last().copied());
assert_eq!(draft.after, None);
assert!(!compatible_source(&f.p, draft, f.exchange));
assert!(compatible_source(&f.p, draft, final_exchange));
state
.update(
&f.p,
Event::Target(Choice {
id: f.exchange,
label: "Incompatible".into(),
}),
)
.unwrap();
assert!(state.prepare_pending(&f.p).is_err());
assert!(state.draft.is_some());
state
.update(
&f.p,
Event::Target(Choice {
id: final_exchange,
label: "Recovery".into(),
}),
)
.unwrap();
let command = state.prepare_pending(&f.p).unwrap();
let Command::Batch(commands) = &command else {
panic!("one ordinary batch")
};
assert_eq!(
commands.len(),
4,
"materialize old path + occurrence + append link + order"
);
let serialized = serde_json::to_value(command).unwrap();
let result = execute_batch(&f.p, &[serde_json::from_value(serialized).unwrap()]).unwrap();
let after = result.elements[&f.owner].steps().unwrap();
assert_eq!(&after[..2], &before);
assert_eq!(after.len(), 3);
let links = sequence_links(&result, f.owner);
assert_eq!(links.len(), 2);
for pair in after.windows(2) {
assert_eq!(
links
.iter()
.filter(|r| r.source() == pair[0] && r.target() == pair[1])
.count(),
1
);
}
assert!(uses_explicit_topology(&result, f.owner));
for (id, relation) in &f.p.relations {
assert_eq!(result.relations[id], *relation);
}
}
}
@@ -155,7 +155,7 @@ pub(super) fn view<'a>(
.spacing(6),
);
}
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else{match state.tool{
out=out.push(label(if pending{"Pending contribution · apply or cancel below"}else if !explicit{"Ordered contributions · Append exchange continues the path"}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"}}));
@@ -226,8 +226,14 @@ pub(super) fn view<'a>(
);
} else {
out = out.push(label(
"Ordered exchange path · convert explicitly before drawing topology.",
"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(
@@ -235,7 +241,7 @@ pub(super) fn view<'a>(
.on_press(Message::Command(unique_batch(p, commands))),
);
} else {
out=out.push(label("A single exchange has no path link to convert. Involve work, a control or a reusable process to begin explicit topology."))
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));
@@ -309,6 +315,14 @@ pub(super) fn view<'a>(
p.named(link.target()),
guard
)));
} else if d.fixed
&& d.tool == Tool::Exchange
&& let Some(before) = d.before
{
form = form.push(label(format!(
"Append after {} → new exchange · Create adds an explicit sequence link",
p.named(before)
)));
} else if d.fixed && d.tool != Tool::Connect {
form = form.push(label(
d.insertion