Exercise exact Scenario reorder sessions and geometric destination candidates
This commit is contained in:
@@ -7,6 +7,12 @@ use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_reorder;
|
||||
// Candidate interaction foundation: production input is enabled only after the
|
||||
// destination experiment and the App lifetime adapter have been verified.
|
||||
#[cfg(test)]
|
||||
mod reorder_session;
|
||||
#[cfg(test)]
|
||||
mod reorder_slot_experiments;
|
||||
pub(crate) mod selection;
|
||||
pub(crate) mod spacing;
|
||||
use super::scenario_layout::{
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Immutable semantic reorder operation, independent of its destination UI.
|
||||
use super::{
|
||||
Context, Slot,
|
||||
group_reorder::{Plan, Prepared},
|
||||
presentation, selection,
|
||||
};
|
||||
use crate::{diagram_selection::Snapshot, diagram_usage::Target};
|
||||
use oa_core::{Command, Id, Project, execute_batch};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Outcome {
|
||||
Change,
|
||||
Noop,
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
pub(crate) struct Preview {
|
||||
pub(crate) slot: Slot,
|
||||
pub(crate) before: Vec<Id>,
|
||||
pub(crate) after: Vec<Id>,
|
||||
pub(crate) outcome: Outcome,
|
||||
/// Geometry status is independent of semantic validity; requested Saved
|
||||
/// representation is retained even when presentation reports fallback.
|
||||
pub(crate) presentation: Option<presentation::ScenarioPresentation>,
|
||||
command: Option<Box<Command>>,
|
||||
}
|
||||
|
||||
pub(crate) struct Completion {
|
||||
pub(crate) command: Option<Box<Command>>,
|
||||
pub(crate) snapshot: Snapshot,
|
||||
pub(crate) representation: super::Representation,
|
||||
pub(crate) presentation_status: presentation::PresentationStatus,
|
||||
}
|
||||
|
||||
pub(crate) struct Session {
|
||||
project: Arc<Project>,
|
||||
context: Context,
|
||||
snapshot: Snapshot,
|
||||
token: Id,
|
||||
primary: Id,
|
||||
parent: Id,
|
||||
before: Vec<Id>,
|
||||
prepared: Prepared,
|
||||
preview: Option<Preview>,
|
||||
#[cfg(test)]
|
||||
proposal_builds: usize,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub(crate) fn new(
|
||||
project: Arc<Project>,
|
||||
context: Context,
|
||||
snapshot: Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Self, String> {
|
||||
if token.is_nil()
|
||||
|| context.document.is_nil()
|
||||
|| context.visit.is_nil()
|
||||
|| snapshot.context() != selection::context(context)
|
||||
|| presentation::checked_owner(&project, context.representation)
|
||||
!= Some(context.scenario)
|
||||
{
|
||||
return Err("The reorder input no longer matches this scenario view.".into());
|
||||
}
|
||||
let Some(Target::ScenarioObject(primary)) = snapshot.primary() else {
|
||||
return Err("Select ordered scenario occurrences.".into());
|
||||
};
|
||||
let primary = *primary;
|
||||
let visible = selection::visible(&project, context, primary);
|
||||
if !snapshot
|
||||
.targets()
|
||||
.contains(&Target::ScenarioObject(primary))
|
||||
|| snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.any(|target| !visible.contains(target))
|
||||
{
|
||||
return Err("Every selected occurrence must be visible in this scenario view.".into());
|
||||
}
|
||||
let prepared = Prepared::new(project.clone(), context.scenario, snapshot.targets())?;
|
||||
let parent = project.elements[&primary]
|
||||
.parent
|
||||
.expect("prepared occurrence parent");
|
||||
let before = project.elements[&parent]
|
||||
.steps()
|
||||
.expect("prepared sequence")
|
||||
.clone();
|
||||
Ok(Self {
|
||||
project,
|
||||
context,
|
||||
snapshot,
|
||||
token,
|
||||
primary,
|
||||
parent,
|
||||
before,
|
||||
prepared,
|
||||
preview: None,
|
||||
#[cfg(test)]
|
||||
proposal_builds: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
pub(crate) fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
pub(crate) fn snapshot(&self) -> &Snapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
pub(crate) fn parent(&self) -> Id {
|
||||
self.parent
|
||||
}
|
||||
pub(crate) fn before(&self) -> &[Id] {
|
||||
&self.before
|
||||
}
|
||||
|
||||
pub(crate) fn preview(&mut self, slot: Slot) -> &Preview {
|
||||
if self
|
||||
.preview
|
||||
.as_ref()
|
||||
.is_none_or(|preview| preview.slot != slot)
|
||||
{
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.proposal_builds += 1;
|
||||
}
|
||||
let result = self.prepared.plan(slot).and_then(|plan| match plan {
|
||||
Plan::Noop => Ok((None, self.project.clone())),
|
||||
Plan::Change(command) => {
|
||||
let candidate =
|
||||
execute_batch(&self.project, std::slice::from_ref(command.as_ref()))
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok((Some(command), Arc::new(candidate)))
|
||||
}
|
||||
});
|
||||
let preview = match result {
|
||||
Ok((command, candidate)) => Preview {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: candidate.elements[&self.parent]
|
||||
.steps()
|
||||
.expect("validated sequence")
|
||||
.clone(),
|
||||
outcome: if command.is_some() {
|
||||
Outcome::Change
|
||||
} else {
|
||||
Outcome::Noop
|
||||
},
|
||||
presentation: Some(presentation::present(
|
||||
&candidate,
|
||||
self.context.representation,
|
||||
self.primary,
|
||||
)),
|
||||
command,
|
||||
},
|
||||
Err(reason) => Preview {
|
||||
slot,
|
||||
before: self.before.clone(),
|
||||
after: self.before.clone(),
|
||||
outcome: Outcome::Refused(reason),
|
||||
presentation: None,
|
||||
command: None,
|
||||
},
|
||||
};
|
||||
self.preview = Some(preview);
|
||||
}
|
||||
self.preview.as_ref().expect("explicit destination preview")
|
||||
}
|
||||
|
||||
/// Consume once; never execute a Document transaction or alter selection.
|
||||
pub(crate) fn finish(
|
||||
self,
|
||||
current: &Project,
|
||||
context: Context,
|
||||
snapshot: &Snapshot,
|
||||
token: Id,
|
||||
) -> Result<Completion, String> {
|
||||
if current != self.project.as_ref()
|
||||
|| context != self.context
|
||||
|| snapshot != &self.snapshot
|
||||
|| token != self.token
|
||||
{
|
||||
return Err("The reorder input changed; choose the operation again.".into());
|
||||
}
|
||||
let preview = self
|
||||
.preview
|
||||
.ok_or("Choose an explicit destination before applying reorder.")?;
|
||||
if let Outcome::Refused(reason) = preview.outcome {
|
||||
return Err(reason);
|
||||
}
|
||||
let presentation_status = preview
|
||||
.presentation
|
||||
.expect("valid plan presentation")
|
||||
.status()
|
||||
.clone();
|
||||
Ok(Completion {
|
||||
command: preview.command,
|
||||
snapshot: self.snapshot,
|
||||
representation: self.context.representation,
|
||||
presentation_status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,354 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
behavior::{
|
||||
scenario::{Representation, direct_tests},
|
||||
topology::occurrence,
|
||||
},
|
||||
diagram_selection::Selection,
|
||||
};
|
||||
use oa_core::{Diagram, DiagramLayout, Layer, OccurrenceBody, ProjectionKind, ScenarioLayout};
|
||||
|
||||
fn row(project: &mut Project, parent: Id, name: &str) -> Id {
|
||||
let element = occurrence(
|
||||
project,
|
||||
parent,
|
||||
name,
|
||||
OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
);
|
||||
let id = element.id;
|
||||
project.elements.insert(id, element);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(id);
|
||||
id
|
||||
}
|
||||
fn snapshot(context: Context, ids: &[Id]) -> Snapshot {
|
||||
let mut selection = Selection::new(selection::context(context).scope);
|
||||
for id in ids {
|
||||
selection.add(Target::ScenarioObject(*id), |_| true);
|
||||
}
|
||||
selection.snapshot(selection::context(context)).unwrap()
|
||||
}
|
||||
fn session(project: &Project, context: Context, ids: &[Id]) -> Session {
|
||||
Session::new(
|
||||
Arc::new(project.clone()),
|
||||
context,
|
||||
snapshot(context, ids),
|
||||
Id::new_v4(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_destination_cache_and_exact_whole_model_saved_presentation() {
|
||||
for mode in 0..3 {
|
||||
let f = direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let x = row(&mut project, f.owner, "X");
|
||||
let y = row(&mut project, f.owner, "Y");
|
||||
let mut context = direct_tests::context(f.owner);
|
||||
for name in ["A", "B"] {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: name.into(),
|
||||
name: name.into(),
|
||||
description: "Keep this view".into(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![oa_core::ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: if name == "A" { 180. } else { 220. },
|
||||
}],
|
||||
messages: [(f.call, 600.), (f.reply, 800.)]
|
||||
.into_iter()
|
||||
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
if mode != 0 && name == "A" {
|
||||
context.representation = Representation::Saved { diagram: id };
|
||||
if mode == 2 {
|
||||
project.diagrams.get_mut(&id).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: [(f.call, 600.), (f.reply, 200.)]
|
||||
.into_iter()
|
||||
.map(|(occurrence, send_y)| oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut operation = session(&project, context, &[f.reply, f.call]);
|
||||
assert_eq!(operation.parent(), f.owner);
|
||||
assert_eq!(operation.before(), &[f.call, f.reply, x, y]);
|
||||
assert_eq!(operation.context(), context);
|
||||
let original_snapshot = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
let slot = Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
};
|
||||
let preview = operation.preview(slot);
|
||||
assert_eq!(preview.before, [f.call, f.reply, x, y]);
|
||||
assert_eq!(preview.after, [x, y, f.call, f.reply]);
|
||||
assert_eq!(preview.outcome, Outcome::Change);
|
||||
assert_eq!(
|
||||
preview.presentation.as_ref().unwrap().requested(),
|
||||
context.representation
|
||||
);
|
||||
assert_eq!(operation.proposal_builds, 1);
|
||||
operation.preview(slot);
|
||||
assert_eq!(operation.proposal_builds, 1);
|
||||
operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
});
|
||||
assert_eq!(operation.proposal_builds, 2);
|
||||
operation.preview(slot);
|
||||
assert_eq!(operation.proposal_builds, 3);
|
||||
assert_eq!(operation.preview.as_ref().unwrap().slot, slot);
|
||||
let completion = operation
|
||||
.finish(&project, context, &original_snapshot, token)
|
||||
.unwrap();
|
||||
assert_eq!(completion.snapshot, original_snapshot);
|
||||
assert_eq!(completion.representation, context.representation);
|
||||
if mode == 2 {
|
||||
assert!(matches!(
|
||||
completion.presentation_status,
|
||||
presentation::PresentationStatus::Fallback { .. }
|
||||
));
|
||||
} else {
|
||||
assert!(matches!(
|
||||
completion.presentation_status,
|
||||
presentation::PresentationStatus::Ready
|
||||
));
|
||||
}
|
||||
let mut expected = project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![x, y, f.call, f.reply];
|
||||
assert_eq!(
|
||||
execute_batch(&project, &[*completion.command.unwrap()]).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.diagrams, project.diagrams);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_destination_is_not_noop_and_selected_anchor_is_explicit_noop() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let captured = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
assert!(
|
||||
operation
|
||||
.finish(&f.project, context, &captured, token)
|
||||
.is_err()
|
||||
);
|
||||
operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let token = operation.token();
|
||||
assert_eq!(
|
||||
operation
|
||||
.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.reply)
|
||||
})
|
||||
.outcome,
|
||||
Outcome::Noop
|
||||
);
|
||||
assert!(
|
||||
operation
|
||||
.finish(&f.project, context, &captured, token)
|
||||
.unwrap()
|
||||
.command
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn temporal_candidate_refuses_without_changing_baseline_or_selection() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut project = f.project;
|
||||
let later = row(&mut project, f.owner, "Later action");
|
||||
let mut operation = session(&project, context, &[f.reply]);
|
||||
let captured = operation.snapshot().clone();
|
||||
let token = operation.token();
|
||||
// A previously valid proposal must not survive a new refused destination.
|
||||
let valid = operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
});
|
||||
assert_eq!(valid.outcome, Outcome::Change);
|
||||
assert_eq!(valid.after, [f.call, later, f.reply]);
|
||||
let preview = operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: Some(f.call),
|
||||
});
|
||||
assert!(matches!(&preview.outcome, Outcome::Refused(reason) if reason.contains("reply_order")));
|
||||
assert!(preview.presentation.is_none());
|
||||
assert_eq!(preview.before, preview.after);
|
||||
assert!(
|
||||
operation
|
||||
.finish(&project, context, &captured, token)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_cross_parent_and_unrendered_sets_never_reduce_to_primary() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
let mut project = f.project;
|
||||
let other = project
|
||||
.add_element(oa_core::Kind::Scenario, Layer::Oa, "Other", None)
|
||||
.unwrap();
|
||||
let foreign = row(&mut project, other, "Foreign");
|
||||
let fragment = occurrence(
|
||||
&project,
|
||||
f.owner,
|
||||
"Fragment",
|
||||
OccurrenceBody::Fragment {
|
||||
operator: oa_core::FragmentOperator::Alternative,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let fragment_id = fragment.id;
|
||||
project.elements.insert(fragment_id, fragment);
|
||||
project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(fragment_id);
|
||||
let branch = occurrence(
|
||||
&project,
|
||||
fragment_id,
|
||||
"Branch",
|
||||
OccurrenceBody::Branch {
|
||||
guard: "ready".into(),
|
||||
occurrences: vec![],
|
||||
},
|
||||
);
|
||||
let branch_id = branch.id;
|
||||
project.elements.insert(branch_id, branch);
|
||||
if let Some(oa_core::SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&fragment_id).unwrap().shared_mut()
|
||||
{
|
||||
branches.push(branch_id);
|
||||
}
|
||||
let nested = row(&mut project, branch_id, "Nested");
|
||||
assert!(
|
||||
selection::visible(&project, context, f.call).contains(&Target::ScenarioObject(nested))
|
||||
);
|
||||
for ids in [
|
||||
vec![f.sender, f.call],
|
||||
vec![nested, f.call],
|
||||
vec![foreign, f.call],
|
||||
vec![Id::new_v4(), f.call],
|
||||
] {
|
||||
assert!(
|
||||
Session::new(
|
||||
Arc::new(project.clone()),
|
||||
context,
|
||||
snapshot(context, &ids),
|
||||
Id::new_v4()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_checks_every_immutable_input_and_constructor_rejects_nil_lifetimes() {
|
||||
let f = direct_tests::fixture();
|
||||
let context = direct_tests::context(f.owner);
|
||||
for altered in 0..8 {
|
||||
let mut operation = session(&f.project, context, &[f.call, f.reply]);
|
||||
let mut token = operation.token();
|
||||
let mut captured = operation.snapshot().clone();
|
||||
let mut current = f.project.clone();
|
||||
let mut live = context;
|
||||
operation.preview(Slot {
|
||||
parent: f.owner,
|
||||
before: None,
|
||||
});
|
||||
match altered {
|
||||
0 => {
|
||||
current.elements.get_mut(&f.call).unwrap().name.push('!');
|
||||
}
|
||||
1 => live.epoch += 1,
|
||||
2 => captured = snapshot(context, &[f.reply, f.call]),
|
||||
3 => token = Id::new_v4(),
|
||||
4 => live.visit = Id::new_v4(),
|
||||
5 => live.document = Id::new_v4(),
|
||||
6 => live.revision += 1,
|
||||
_ => {
|
||||
let mut selection = Selection::new(selection::context(context).scope);
|
||||
selection.add(Target::ScenarioObject(f.call), |_| true);
|
||||
selection.add(Target::ScenarioObject(f.reply), |_| true);
|
||||
selection.set_primary(&Target::ScenarioObject(f.call));
|
||||
selection.set_primary(&Target::ScenarioObject(f.reply));
|
||||
captured = selection.snapshot(selection::context(context)).unwrap();
|
||||
assert_eq!(captured.targets(), operation.snapshot().targets());
|
||||
assert_eq!(captured.primary(), operation.snapshot().primary());
|
||||
assert_ne!(captured.generation(), operation.snapshot().generation());
|
||||
}
|
||||
}
|
||||
assert!(operation.finish(¤t, live, &captured, token).is_err());
|
||||
}
|
||||
for altered in 0..3 {
|
||||
let mut live = context;
|
||||
let mut token = Id::new_v4();
|
||||
match altered {
|
||||
0 => live.document = Id::nil(),
|
||||
1 => live.visit = Id::nil(),
|
||||
_ => token = Id::nil(),
|
||||
}
|
||||
assert!(
|
||||
Session::new(
|
||||
Arc::new(f.project.clone()),
|
||||
live,
|
||||
snapshot(live, &[f.call]),
|
||||
token
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Candidate-input experiment, not an accepted new interaction policy.
|
||||
//! Exercise production presentation, nearest-Y picking, and exact semantic slots.
|
||||
use super::{Representation, Slot, group_reorder, presentation};
|
||||
use crate::{behavior::Scene, diagram_usage::Target};
|
||||
use iced::Point;
|
||||
use oa_core::{
|
||||
Diagram, DiagramLayout, Id, OccurrenceEndpoint, Project, ProjectionKind, ScenarioLayout,
|
||||
ScenarioMessagePosition, execute_batch, language, validation,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
diagram: Id,
|
||||
ids: [Id; 4],
|
||||
}
|
||||
|
||||
fn fixture(ys: [f32; 4]) -> Fixture {
|
||||
let mut project = language::parse(SOURCE).unwrap();
|
||||
let named = |name: &str| {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, alias)| *alias == name)
|
||||
.unwrap()
|
||||
.0
|
||||
};
|
||||
let owner = named("conversation");
|
||||
let ids = [named("a"), named("b"), named("c"), named("d")];
|
||||
let diagram = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
diagram,
|
||||
Diagram {
|
||||
id: diagram,
|
||||
alias: "saved".into(),
|
||||
name: "Authored coordinates".into(),
|
||||
description: String::new(),
|
||||
layer: project.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![],
|
||||
messages: ids
|
||||
.into_iter()
|
||||
.zip(ys)
|
||||
.map(|(occurrence, send_y)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
})),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
validation::validate(&project)
|
||||
.iter()
|
||||
.all(|issue| issue.severity != validation::Severity::Error)
|
||||
);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
diagram,
|
||||
ids,
|
||||
}
|
||||
}
|
||||
|
||||
fn shown(f: &Fixture) -> presentation::ScenarioPresentation {
|
||||
let shown = presentation::present(
|
||||
&f.project,
|
||||
Representation::Saved { diagram: f.diagram },
|
||||
f.ids[0],
|
||||
);
|
||||
assert_eq!(shown.status(), &presentation::PresentationStatus::Ready);
|
||||
assert_eq!(shown.requested(), shown.geometry_representation().unwrap());
|
||||
shown
|
||||
}
|
||||
|
||||
fn before(f: &Fixture, id: Id) -> Slot {
|
||||
Slot {
|
||||
parent: f.owner,
|
||||
before: Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
fn y(scene: &Scene, slot: Slot) -> f32 {
|
||||
scene.slots.iter().find(|item| item.slot == slot).unwrap().y
|
||||
}
|
||||
|
||||
fn picked(scene: &Scene, f: &Fixture, world_y: f32) -> Slot {
|
||||
scene
|
||||
.slot_at(Point::new(100., world_y), Some(f.owner))
|
||||
.unwrap()
|
||||
.slot
|
||||
}
|
||||
|
||||
fn exact_change(f: &Fixture, selected: &[Id], slot: Slot, order: &[Id]) {
|
||||
let prepared = group_reorder::Prepared::new(
|
||||
Arc::new(f.project.clone()),
|
||||
f.owner,
|
||||
&selected
|
||||
.iter()
|
||||
.copied()
|
||||
.map(Target::ScenarioObject)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
let group_reorder::Plan::Change(command) = prepared.plan(slot).unwrap() else {
|
||||
panic!("expected an actual semantic change")
|
||||
};
|
||||
let actual = execute_batch(&f.project, std::slice::from_ref(command.as_ref())).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
*expected
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = order.to_vec();
|
||||
// Literal full-model oracle: every authored position and other field survives.
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&actual)).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
presentation::present(
|
||||
&actual,
|
||||
Representation::Saved { diagram: f.diagram },
|
||||
selected[0]
|
||||
)
|
||||
.status(),
|
||||
&presentation::PresentationStatus::Ready
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossed_saved_slots_are_reachable_but_screen_order_is_not_semantic_order() {
|
||||
let f = fixture([200., 500., 300., 400.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(
|
||||
f.ids.map(|id| y(scene, before(&f, id))),
|
||||
[158., 458., 258., 358.]
|
||||
);
|
||||
let mut spatial = f.ids.to_vec();
|
||||
spatial.sort_by(|a, b| y(scene, before(&f, *a)).total_cmp(&y(scene, before(&f, *b))));
|
||||
assert_eq!(spatial, [a, c, d, b]);
|
||||
for id in f.ids {
|
||||
assert_eq!(picked(scene, &f, y(scene, before(&f, id))), before(&f, id));
|
||||
}
|
||||
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_saved_slots_hide_one_destination_while_exact_chooser_can_select_it() {
|
||||
let f = fixture([200., 300., 300., 500.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, b)), 258.);
|
||||
assert_eq!(y(scene, before(&f, c)), 258.);
|
||||
assert!(
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.position(|item| item.slot == before(&f, b))
|
||||
< scene
|
||||
.slots
|
||||
.iter()
|
||||
.position(|item| item.slot == before(&f, c))
|
||||
);
|
||||
// The two distances are identical for every finite Y; min_by keeps the first
|
||||
// tie. The sample exercises that production behavior, not an exhaustive proof.
|
||||
for quarter_pixel in -4000..=12000 {
|
||||
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, c));
|
||||
}
|
||||
assert_eq!(picked(scene, &f, 258.), before(&f, b));
|
||||
exact_change(&f, &[d, a], before(&f, c), &[b, a, d, c]);
|
||||
exact_change(&f, &[d, a], picked(scene, &f, 258.), &[a, d, b, c]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_saved_slots_have_subpixel_hit_regions_without_becoming_unreachable_continuously() {
|
||||
let f = fixture([200., 300., 300.125, 300.25]);
|
||||
let [_, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, b)), 258.);
|
||||
assert_eq!(y(scene, before(&f, c)), 258.125);
|
||||
assert_eq!(y(scene, before(&f, d)), 258.25);
|
||||
assert_eq!(picked(scene, &f, 258.125), before(&f, c));
|
||||
// Fixed scale=1/pan=0: C's open Voronoi interval is only
|
||||
// (258.0625, 258.1875). Integer-pixel samples miss it. This does not assert
|
||||
// that all platforms quantize pointer input to integer coordinates.
|
||||
for screen_y in 0..=600 {
|
||||
assert_ne!(picked(scene, &f, screen_y as f32), before(&f, c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_saved_slot_needs_camera_change_or_nonspatial_destination_input() {
|
||||
let f = fixture([200., 300., 400., 2000.]);
|
||||
let [a, b, c, d] = f.ids;
|
||||
let shown = shown(&f);
|
||||
let scene = shown.scene().unwrap();
|
||||
assert_eq!(y(scene, before(&f, d)), 1958.);
|
||||
// Fixed scale=1/pan=0 and a 600px-high canvas. The C/D midpoint is
|
||||
// 1158, beyond the whole viewport, so D cannot win within that viewport.
|
||||
for quarter_pixel in 0..=2400 {
|
||||
assert_ne!(picked(scene, &f, quarter_pixel as f32 / 4.), before(&f, d));
|
||||
}
|
||||
assert_eq!(picked(scene, &f, 1958.), before(&f, d));
|
||||
exact_change(&f, &[c, a], before(&f, d), &[b, a, c, d]);
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project slot_probe "Slot probe" id "71000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "71000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "71000000-0000-4000-8000-000000000003" {}
|
||||
oa_scenario conversation "Conversation" id "71000000-0000-4000-8000-000000000004" { viewpoint OperationalActivity; steps [@a, @b, @c, @d]; }
|
||||
oa_lifeline left "Left" id "71000000-0000-4000-8000-000000000005" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "71000000-0000-4000-8000-000000000006" { parent @conversation; represented @target; }
|
||||
oa_occurrence a "A" id "71000000-0000-4000-8000-000000000007" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence b "B" id "71000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence c "C" id "71000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence d "D" id "71000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
Reference in New Issue
Block a user