Fix exchange-only process continuation with explicit append path
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user