1661 lines
59 KiB
Rust
1661 lines
59 KiB
Rust
use super::*;
|
|
use crate::document::Document;
|
|
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
|
|
|
|
struct Fixture {
|
|
p: Project,
|
|
d: Id,
|
|
second: Id,
|
|
a: Id,
|
|
b: Id,
|
|
w: Id,
|
|
n: Id,
|
|
}
|
|
|
|
fn xy(x: f32, y: f32) -> Position {
|
|
Position { x, y }
|
|
}
|
|
|
|
fn fixture(layer: Layer) -> Fixture {
|
|
let mut p = Project::empty();
|
|
let (a_kind, b_kind, work) = match layer {
|
|
Layer::Oa => (Kind::Entity, Kind::Actor, Kind::Activity),
|
|
Layer::Sa => (Kind::System, Kind::SystemActor, Kind::Function),
|
|
};
|
|
let a = p.add_element(a_kind, layer, "A", None).unwrap();
|
|
let b = p.add_element(b_kind, layer, "B", None).unwrap();
|
|
let w = p.add_element(work, layer, "Work", None).unwrap();
|
|
let d = p.ensure_diagram(layer);
|
|
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
|
p.place(d, a, xy(100., 100.));
|
|
p.place(d, b, xy(900., 100.));
|
|
let n = p.place(d, w, xy(100., 500.));
|
|
let second = p.ensure_diagram(layer);
|
|
p.place(second, a, xy(20., 30.));
|
|
p.place(second, w, xy(500., 900.));
|
|
validate_candidate(&p).unwrap();
|
|
Fixture {
|
|
p,
|
|
d,
|
|
second,
|
|
a,
|
|
b,
|
|
w,
|
|
n,
|
|
}
|
|
}
|
|
|
|
fn scope(f: &Fixture) -> Scope {
|
|
capture(&f.p, f.d, f.w, f.n).unwrap()
|
|
}
|
|
|
|
fn presentation(p: &Project, d: Id, target: Option<Id>) -> PresentationContext {
|
|
let target = target.map(|element| {
|
|
let view = projection::project_view(p, d);
|
|
let node = view.nodes.iter().find(|n| n.element == element).unwrap();
|
|
TargetPresentation { placement: node.id }
|
|
});
|
|
PresentationContext {
|
|
viewport: Rect {
|
|
position: xy(-100., -100.),
|
|
size: Size {
|
|
width: 3000.,
|
|
height: 2400.,
|
|
},
|
|
},
|
|
zoom: 1.,
|
|
target,
|
|
}
|
|
}
|
|
|
|
fn allocation(f: &mut Fixture, performer: Id) -> Id {
|
|
f.p.relate(
|
|
RelationKind::Allocation,
|
|
"Original responsibility",
|
|
performer,
|
|
f.w,
|
|
)
|
|
}
|
|
|
|
fn semantic(f: &Fixture, destination: Destination) -> SemanticPlan {
|
|
plan_semantics(
|
|
&f.p,
|
|
&scope(f),
|
|
destination,
|
|
&BTreeMap::from([(f.w, Id::new_v4())]),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn command(f: &Fixture, destination: Destination, requested: Position) -> Command {
|
|
let plan = semantic(f, destination);
|
|
let target = match destination {
|
|
Destination::Performer(id) => Some(id),
|
|
_ => None,
|
|
};
|
|
let placed = place(&plan, requested, presentation(&f.p, f.d, target)).unwrap();
|
|
finish(&plan, &f.p, &placed).unwrap().unwrap()
|
|
}
|
|
|
|
fn roundtrip(p: &Project, command: Command) -> Project {
|
|
let mut bundle = format_bundle(p);
|
|
bundle
|
|
.files
|
|
.get_mut(&bundle.entry.clone())
|
|
.unwrap()
|
|
.insert_str(
|
|
0,
|
|
"// Allocation review rationale: preserve this source exactly.\n",
|
|
);
|
|
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
|
let before_source = document.bundle().clone();
|
|
let expected = prepare_command(p, &command).unwrap().project;
|
|
document.execute(command).unwrap();
|
|
assert_eq!(document.project(), &expected);
|
|
assert_eq!(document.revision(), 1);
|
|
let after_source = document.bundle().clone();
|
|
assert!(after_source.files[&after_source.entry].starts_with("// Allocation review rationale:"));
|
|
assert_eq!(
|
|
compile_bundle(&after_source, ProjectLimits::default())
|
|
.unwrap()
|
|
.decoded
|
|
.project,
|
|
expected
|
|
);
|
|
let reopened = Document::from_bundle(after_source.clone(), ProjectLimits::default()).unwrap();
|
|
assert_eq!(reopened.project(), &expected);
|
|
assert!(document.undo());
|
|
assert_eq!(document.project(), p);
|
|
assert_eq!(document.bundle(), &before_source);
|
|
assert!(!document.can_undo(), "one drag is exactly one history step");
|
|
assert!(document.redo());
|
|
assert_eq!(document.project(), &expected);
|
|
assert_eq!(document.bundle(), &after_source);
|
|
assert!(!document.can_redo());
|
|
expected
|
|
}
|
|
|
|
#[test]
|
|
fn oa_sa_incoming_between_outgoing_preserve_identity_source_history_and_other_views() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let elements = f.p.elements.clone();
|
|
let other = f.p.diagrams[&f.second].clone();
|
|
let stable = Id::new_v4();
|
|
let plan = plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::Performer(f.a),
|
|
&BTreeMap::from([(f.w, stable)]),
|
|
)
|
|
.unwrap();
|
|
let same_target_again = plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::Performer(f.a),
|
|
&BTreeMap::from([(f.w, stable)]),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(plan.project(), same_target_again.project());
|
|
let placed = place(&plan, xy(151., 203.), presentation(&f.p, f.d, Some(f.a))).unwrap();
|
|
assert_eq!(placed.delta, xy(50., -300.));
|
|
let batch = finish(&plan, &f.p, &placed).unwrap().unwrap();
|
|
f.p = roundtrip(&f.p, batch);
|
|
assert_eq!(
|
|
f.p.relations[&stable].body,
|
|
RelationBody::Allocation {
|
|
source: f.a,
|
|
target: f.w
|
|
}
|
|
);
|
|
let record = f.p.relations.get_mut(&stable).unwrap();
|
|
record.name = "Reviewed responsibility".into();
|
|
record.description = "Retain engineering rationale and relation identity".into();
|
|
let original = record.clone();
|
|
let batch = command(&f, Destination::Performer(f.b), xy(950., 200.));
|
|
f.p = roundtrip(&f.p, batch);
|
|
let mut expected = original;
|
|
expected.body = RelationBody::Allocation {
|
|
source: f.b,
|
|
target: f.w,
|
|
};
|
|
assert_eq!(f.p.relations[&stable], expected);
|
|
let batch = command(&f, Destination::OutsidePerformers, xy(100., 500.));
|
|
f.p = roundtrip(&f.p, batch);
|
|
assert!(!f.p.relations.contains_key(&stable));
|
|
assert_eq!(f.p.elements, elements);
|
|
assert_eq!(f.p.diagrams[&f.second], other);
|
|
assert_eq!(f.p.diagrams[&f.d].nodes[&f.n].position, xy(100., 500.));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn snapped_noop_returns_no_command_and_keeps_source_revision_and_redo() {
|
|
let f = fixture(Layer::Oa);
|
|
let plan = semantic(&f, Destination::OutsidePerformers);
|
|
let placed = place(&plan, xy(103., 498.), presentation(&f.p, f.d, None)).unwrap();
|
|
assert_eq!(placed.delta, xy(0., 0.));
|
|
assert!(finish(&plan, &f.p, &placed).unwrap().is_none());
|
|
let mut document = Document::from_project(f.p.clone(), ProjectLimits::default()).unwrap();
|
|
document
|
|
.execute(Command::Rename(f.w, "Changed work".into()))
|
|
.unwrap();
|
|
document.undo();
|
|
let source = document.bundle().clone();
|
|
let revision = document.revision();
|
|
if let Some(command) = finish(&plan, document.project(), &placed).unwrap() {
|
|
document.execute(command).unwrap();
|
|
}
|
|
assert_eq!(document.bundle(), &source);
|
|
assert_eq!(document.revision(), revision);
|
|
assert!(document.can_redo());
|
|
}
|
|
|
|
fn role(f: &mut Fixture, performer: Option<Id>) -> (Id, Id) {
|
|
let role =
|
|
f.p.add_element(Kind::Role, Layer::Oa, "Operational responsibility", None)
|
|
.unwrap();
|
|
let assignment =
|
|
f.p.relate(RelationKind::RoleAssignment, "Role assignment", role, f.w);
|
|
if let Some(performer) = performer {
|
|
f.p.relate(
|
|
RelationKind::RoleRealization,
|
|
"Role realization",
|
|
performer,
|
|
role,
|
|
);
|
|
}
|
|
(role, assignment)
|
|
}
|
|
|
|
#[test]
|
|
fn role_only_same_effective_performer_is_semantic_noop_and_outgoing_refuses() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let a = f.a;
|
|
let (role, _) = role(&mut f, Some(a));
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.n)
|
|
.unwrap()
|
|
.position = xy(150., 200.);
|
|
let s = scope(&f);
|
|
assert_eq!(s.uniform, Uniform::Assigned(a));
|
|
assert!(s.leaves[&f.w].direct.is_none());
|
|
let plan = plan_semantics(&f.p, &s, Destination::Performer(a), &BTreeMap::new()).unwrap();
|
|
assert_eq!(plan.project(), &f.p);
|
|
assert!(plan.commands.is_empty());
|
|
let placed = place(&plan, xy(160., 210.), presentation(&f.p, f.d, Some(a))).unwrap();
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert_eq!(after.relations, f.p.relations);
|
|
assert!(
|
|
!after
|
|
.relations
|
|
.values()
|
|
.any(|r| r.kind() == RelationKind::Allocation)
|
|
);
|
|
let before = f.p.clone();
|
|
let Refusal::RoleExitRequired {
|
|
roles,
|
|
performers,
|
|
message,
|
|
..
|
|
} = plan_semantics(&f.p, &s, Destination::OutsidePerformers, &BTreeMap::new()).unwrap_err()
|
|
else {
|
|
panic!("role exit must refuse")
|
|
};
|
|
assert_eq!(roles, vec![role]);
|
|
assert_eq!(performers, vec![a]);
|
|
assert!(message.contains("Operational responsibility"));
|
|
assert_eq!(f.p, before);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_exit_revealing_another_role_performer_has_truthful_refusal() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let (a, b) = (f.a, f.b);
|
|
allocation(&mut f, a);
|
|
role(&mut f, Some(b));
|
|
let Refusal::RoleExitRequired {
|
|
message,
|
|
performers,
|
|
..
|
|
} = plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::OutsidePerformers,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap_err()
|
|
else {
|
|
panic!()
|
|
};
|
|
assert_eq!(performers, vec![b]);
|
|
assert!(message.contains("would place Work in B through its role"));
|
|
}
|
|
|
|
#[test]
|
|
fn unresolved_role_leaf_can_arrange_or_accept_explicit_target_without_guessing() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let (role_id, assignment) = role(&mut f, None);
|
|
let s = scope(&f);
|
|
assert_eq!(s.uniform, Uniform::MixedOrUnresolved);
|
|
let arranged = command(&f, Destination::LayoutOnly, xy(110., 500.));
|
|
assert_eq!(roundtrip(&f.p, arranged).relations, f.p.relations);
|
|
let plan = semantic(&f, Destination::Performer(f.b));
|
|
assert!(
|
|
plan.label
|
|
.contains("explicit assignment; role responsibility retained")
|
|
);
|
|
assert_eq!(
|
|
plan.project().relations[&assignment],
|
|
f.p.relations[&assignment]
|
|
);
|
|
assert_eq!(
|
|
validation::effective_performer(plan.project(), f.w),
|
|
Some(f.b)
|
|
);
|
|
// Ambiguous realization is also a valid warning-only baseline, and B is an
|
|
// explicit user destination even when the responsibility names A and B.
|
|
f.p.relate(RelationKind::RoleRealization, "A role", f.a, role_id);
|
|
f.p.relate(RelationKind::RoleRealization, "B role", f.b, role_id);
|
|
assert_eq!(scope(&f).uniform, Uniform::MixedOrUnresolved);
|
|
assert_eq!(
|
|
validation::effective_performer(semantic(&f, Destination::Performer(f.b)).project(), f.w),
|
|
Some(f.b)
|
|
);
|
|
assert!(matches!(
|
|
plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::OutsidePerformers,
|
|
&BTreeMap::new()
|
|
),
|
|
Err(Refusal::RoleExitRequired { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_different_performer_retains_advisory_role_warning() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let a = f.a;
|
|
role(&mut f, Some(a));
|
|
let plan = semantic(&f, Destination::Performer(f.b));
|
|
assert!(
|
|
plan.warnings
|
|
.iter()
|
|
.any(|i| i.code == "role_allocation_conflict" && i.severity == Severity::Warning)
|
|
);
|
|
let placed = place(&plan, xy(950., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
|
roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
}
|
|
|
|
fn subtree(layer: Layer) -> (Fixture, Vec<Id>, Id, Id) {
|
|
let mut f = fixture(layer);
|
|
let kind = f.p.elements[&f.w].kind();
|
|
let x =
|
|
f.p.add_element(kind, layer, "First child", Some(f.w))
|
|
.unwrap();
|
|
let grand = f.p.add_element(kind, layer, "Grandchild", Some(x)).unwrap();
|
|
let y =
|
|
f.p.add_element(kind, layer, "Second child", Some(f.w))
|
|
.unwrap();
|
|
let unplaced =
|
|
f.p.add_element(kind, layer, "Unplaced leaf", Some(f.w))
|
|
.unwrap();
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.n)
|
|
.unwrap()
|
|
.position = xy(140., 200.);
|
|
let xn = f.p.place(f.d, x, xy(180., 300.));
|
|
f.p.place(f.d, grand, xy(220., 400.));
|
|
f.p.place(f.d, y, xy(180., 550.));
|
|
for work in [grand, y, unplaced] {
|
|
f.p.relate(RelationKind::Allocation, "Leaf responsibility", f.a, work);
|
|
}
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.n)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&xn)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
(f, vec![grand, y, unplaced], x, unplaced)
|
|
}
|
|
|
|
#[test]
|
|
fn uniform_folded_grandchild_and_unplaced_scope_moves_exactly_q_and_all_l_oa_sa() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let (mut f, leaves, _x, unplaced) = subtree(layer);
|
|
let edge_kind = if layer == Layer::Oa {
|
|
RelationKind::Interaction
|
|
} else {
|
|
RelationKind::FunctionalExchange
|
|
};
|
|
let internal = f.p.relate(edge_kind, "Internal flow", leaves[0], leaves[1]);
|
|
if let RelationBody::FunctionalExchange { source_port, .. } = f.p.relations[&internal].body
|
|
{
|
|
let node =
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.values_mut()
|
|
.find(|n| n.element == leaves[0])
|
|
.unwrap();
|
|
node.port_anchors.push(PortAnchor {
|
|
port: source_port,
|
|
side: PortAnchorSide::Bottom,
|
|
offset: 0.4,
|
|
});
|
|
}
|
|
let external =
|
|
f.p.add_element(f.p.elements[&f.w].kind(), layer, "External work", None)
|
|
.unwrap();
|
|
f.p.place(f.d, external, xy(1800., 800.));
|
|
let one_ended = f.p.relate(edge_kind, "External flow", leaves[1], external);
|
|
for diagram in [f.d, f.second] {
|
|
f.p.diagrams.get_mut(&diagram).unwrap().routes =
|
|
vec![route(internal), route(one_ended)];
|
|
}
|
|
let s = scope(&f);
|
|
assert_eq!(s.work_records.len(), 5);
|
|
assert_eq!(
|
|
s.leaves.keys().copied().collect::<BTreeSet<_>>(),
|
|
leaves.iter().copied().collect()
|
|
);
|
|
assert_eq!(s.placements.len(), 4);
|
|
assert!(!s.placements.values().any(|n| n.element == unplaced));
|
|
assert_eq!(s.uniform, Uniform::Assigned(f.a));
|
|
let plan = plan_semantics(&f.p, &s, Destination::Performer(f.b), &BTreeMap::new()).unwrap();
|
|
let placed = place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
|
assert_eq!(placed.delta, xy(800., 0.));
|
|
for (id, before) in &s.placements {
|
|
let mut expected = before.clone();
|
|
expected.position.x += 800.;
|
|
assert_eq!(placed.diagram.nodes[id], expected);
|
|
}
|
|
assert!(placed.view.nodes.iter().all(|n| n.element != unplaced));
|
|
assert!(
|
|
placed
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.all(|n| !leaves.contains(&n.element)),
|
|
"fold state retained"
|
|
);
|
|
let mut translated = route(internal);
|
|
for p in &mut translated.waypoints {
|
|
p.x += 800.;
|
|
}
|
|
assert_eq!(placed.diagram.routes, vec![translated, route(one_ended)]);
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]);
|
|
assert_eq!(
|
|
after.elements, f.p.elements,
|
|
"decomposition, ports and all element metadata remain exact"
|
|
);
|
|
assert_eq!(after.relations.len(), f.p.relations.len());
|
|
for relation in f.p.relations.values() {
|
|
let mut expected = relation.clone();
|
|
if relation.kind() == RelationKind::Allocation && leaves.contains(&relation.target()) {
|
|
expected.body = RelationBody::Allocation {
|
|
source: f.b,
|
|
target: relation.target(),
|
|
};
|
|
}
|
|
assert_eq!(after.relations[&relation.id], expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn uniform_role_only_and_mixed_direct_role_composites_same_target_do_not_allocate() {
|
|
for mixed_direct in [false, true] {
|
|
let (mut f, leaves, _, _) = subtree(Layer::Oa);
|
|
f.p.relations.clear();
|
|
let role =
|
|
f.p.add_element(Kind::Role, Layer::Oa, "Team responsibility", None)
|
|
.unwrap();
|
|
f.p.relate(RelationKind::RoleRealization, "A realizes role", f.a, role);
|
|
for (index, work) in leaves.into_iter().enumerate() {
|
|
f.p.relate(RelationKind::RoleAssignment, "Leaf role", role, work);
|
|
if mixed_direct && index == 0 {
|
|
f.p.relate(RelationKind::Allocation, "Explicit allocation", f.a, work);
|
|
}
|
|
}
|
|
let s = scope(&f);
|
|
assert_eq!(s.uniform, Uniform::Assigned(f.a));
|
|
let plan = plan_semantics(&f.p, &s, Destination::Performer(f.a), &BTreeMap::new()).unwrap();
|
|
assert_eq!(plan.project(), &f.p);
|
|
let placed = place(&plan, xy(150., 210.), presentation(&f.p, f.d, Some(f.a))).unwrap();
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert_eq!(after.relations, f.p.relations);
|
|
let ids: BTreeMap<_, _> = s.leaves.keys().map(|id| (*id, Id::new_v4())).collect();
|
|
let reassign = plan_semantics(&f.p, &s, Destination::Performer(f.b), &ids).unwrap();
|
|
for (work, leaf) in &s.leaves {
|
|
let id = leaf.direct.as_ref().map_or(ids[work], |r| r.id);
|
|
assert_eq!(
|
|
reassign.project().relations[&id].body,
|
|
RelationBody::Allocation {
|
|
source: f.b,
|
|
target: *work
|
|
}
|
|
);
|
|
if let Some(original) = &leaf.direct {
|
|
let mut expected = original.clone();
|
|
expected.body = RelationBody::Allocation {
|
|
source: f.b,
|
|
target: *work,
|
|
};
|
|
assert_eq!(reassign.project().relations[&id], expected);
|
|
}
|
|
}
|
|
for original in
|
|
f.p.relations
|
|
.values()
|
|
.filter(|r| r.kind() != RelationKind::Allocation)
|
|
{
|
|
assert_eq!(reassign.project().relations[&original.id], *original);
|
|
}
|
|
assert!(
|
|
!reassign
|
|
.project()
|
|
.relations
|
|
.values()
|
|
.any(|r| r.kind() == RelationKind::Allocation && r.target() == f.w)
|
|
);
|
|
let placed = place(
|
|
&reassign,
|
|
xy(940., 200.),
|
|
presentation(&f.p, f.d, Some(f.b)),
|
|
)
|
|
.unwrap();
|
|
roundtrip(&f.p, finish(&reassign, &f.p, &placed).unwrap().unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn uniformly_unassigned_composite_creates_only_stable_leaf_allocations_including_unplaced() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let (mut f, leaves, _, _) = subtree(layer);
|
|
f.p.relations.clear();
|
|
let s = scope(&f);
|
|
assert_eq!(s.uniform, Uniform::Unassigned);
|
|
let ids: BTreeMap<_, _> = leaves.iter().map(|id| (*id, Id::new_v4())).collect();
|
|
let plan = plan_semantics(&f.p, &s, Destination::Performer(f.b), &ids).unwrap();
|
|
assert_eq!(plan.project().relations.len(), leaves.len());
|
|
for work in &leaves {
|
|
assert_eq!(
|
|
plan.project().relations[&ids[work]].body,
|
|
RelationBody::Allocation {
|
|
source: f.b,
|
|
target: *work
|
|
}
|
|
);
|
|
}
|
|
assert_eq!(plan.project().elements, f.p.elements);
|
|
assert_eq!(
|
|
plan.project().diagrams,
|
|
f.p.diagrams,
|
|
"semantics planning invents no placements"
|
|
);
|
|
let placed = place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
|
roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_budget_refusal_keeps_project_bundle_history_and_retry_identity_exact() {
|
|
let f = fixture(Layer::Oa);
|
|
let stable = Id::new_v4();
|
|
let plan = plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::Performer(f.a),
|
|
&BTreeMap::from([(f.w, stable)]),
|
|
)
|
|
.unwrap();
|
|
let placed = place(&plan, xy(150., 200.), presentation(&f.p, f.d, Some(f.a))).unwrap();
|
|
let command = finish(&plan, &f.p, &placed).unwrap().unwrap();
|
|
let bundle = format_bundle(&f.p);
|
|
let size: usize = bundle.files.values().map(String::len).sum();
|
|
let mut limits = ProjectLimits::default();
|
|
limits.model.max_source_bytes = size;
|
|
let mut document = Document::from_bundle(bundle, limits).unwrap();
|
|
let source = document.bundle().clone();
|
|
assert!(matches!(
|
|
document.execute(command.clone()),
|
|
Err(crate::document::DocumentError::Source(_))
|
|
));
|
|
assert_eq!(document.project(), &f.p);
|
|
assert_eq!(document.bundle(), &source);
|
|
assert_eq!(document.revision(), 0);
|
|
assert!(!document.can_undo());
|
|
assert!(!document.can_redo());
|
|
// A retry through a document with sufficient admission budget uses the same
|
|
// stable relation identity; no incomplete semantic/layout result escaped.
|
|
let after = roundtrip(&f.p, command);
|
|
assert_eq!(after.relations[&stable].target(), f.w);
|
|
}
|
|
|
|
#[test]
|
|
fn mixed_p_x_y_refuses_semantic_and_arrange_instead_of_stretching_destination() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let (mut f, leaves, _, _) = subtree(layer);
|
|
let allocation =
|
|
f.p.relations
|
|
.values_mut()
|
|
.find(|r| r.kind() == RelationKind::Allocation && r.target() == leaves[1])
|
|
.unwrap();
|
|
allocation.body = RelationBody::Allocation {
|
|
source: f.b,
|
|
target: leaves[1],
|
|
};
|
|
let s = scope(&f);
|
|
assert_eq!(s.uniform, Uniform::MixedOrUnresolved);
|
|
for destination in [
|
|
Destination::Performer(f.a),
|
|
Destination::Performer(f.b),
|
|
Destination::OutsidePerformers,
|
|
Destination::LayoutOnly,
|
|
] {
|
|
assert!(matches!(
|
|
plan_semantics(&f.p, &s, destination, &BTreeMap::new()),
|
|
Err(Refusal::MixedLayoutRequired)
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn collateral(f: &Fixture) -> Vec<ChangedRecord> {
|
|
match plan_semantics(
|
|
&f.p,
|
|
&scope(f),
|
|
Destination::OutsidePerformers,
|
|
&BTreeMap::new(),
|
|
) {
|
|
Err(Refusal::CollateralChangeRequired(records)) => records,
|
|
other => panic!("expected exact diff refusal, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn surviving_constraint_mutation_is_refused_even_when_core_candidate_is_valid() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let a = f.a;
|
|
let allocation = allocation(&mut f, a);
|
|
let constraint =
|
|
f.p.add_element(Kind::Constraint, layer, "Keep reviewed assignment", None)
|
|
.unwrap();
|
|
*f.p.elements
|
|
.get_mut(&constraint)
|
|
.unwrap()
|
|
.shared_mut()
|
|
.unwrap() = SharedBody::Constraint {
|
|
expression: "reviewed assignment".into(),
|
|
constrained: vec![allocation, f.w],
|
|
};
|
|
let actual = prepare_command(&f.p, &Command::Delete(allocation))
|
|
.unwrap()
|
|
.project;
|
|
validate_candidate(&actual).unwrap();
|
|
assert_eq!(
|
|
actual.elements.len(),
|
|
f.p.elements.len(),
|
|
"the counterexample loses no element identity"
|
|
);
|
|
assert_ne!(actual.elements[&constraint], f.p.elements[&constraint]);
|
|
let changes = collateral(&f);
|
|
assert!(changes.iter().any(|r| r.id == constraint
|
|
&& r.category == "element"
|
|
&& r.name == "Keep reviewed assignment"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let a = f.a;
|
|
let allocation = allocation(&mut f, a);
|
|
let artifact = Id::new_v4();
|
|
f.p.artifact_links.insert(
|
|
artifact,
|
|
ArtifactLink {
|
|
id: artifact,
|
|
alias: "assignment_evidence".into(),
|
|
name: "Assignment evidence".into(),
|
|
description: "Reviewed".into(),
|
|
owner: allocation,
|
|
relation: ArtifactRelation::Verifies,
|
|
uri: "evidence/assignment.pdf".into(),
|
|
revision: Some("r1".into()),
|
|
fragment: Some("section-2".into()),
|
|
},
|
|
);
|
|
let sa =
|
|
f.p.add_element(Kind::Function, Layer::Sa, "Counterpart", None)
|
|
.unwrap();
|
|
let trace = Id::new_v4();
|
|
f.p.traces.insert(
|
|
trace,
|
|
Trace {
|
|
id: trace,
|
|
alias: "assignment_trace".into(),
|
|
name: "Assignment trace".into(),
|
|
description: "Reviewed trace".into(),
|
|
oa: allocation,
|
|
sa,
|
|
kind: TraceKind::Manual,
|
|
baseline: Some(TraceBaseline {
|
|
oa: f.p.properties(allocation).unwrap(),
|
|
sa: f.p.properties(sa).unwrap(),
|
|
semantic: None,
|
|
}),
|
|
},
|
|
);
|
|
validate_candidate(&f.p).unwrap();
|
|
let changes = collateral(&f);
|
|
assert!(
|
|
changes
|
|
.iter()
|
|
.any(|r| r.id == artifact && r.category == "artifact")
|
|
);
|
|
assert!(
|
|
changes
|
|
.iter()
|
|
.any(|r| r.id == trace && r.category == "trace")
|
|
);
|
|
// Source retargeting retains the reviewed relation identity, so the same
|
|
// evidence remains valid and byte-identical rather than being cascade-deleted.
|
|
let plan = semantic(&f, Destination::Performer(f.b));
|
|
assert_eq!(plan.project().artifact_links, f.p.artifact_links);
|
|
assert_eq!(plan.project().traces, f.p.traces);
|
|
}
|
|
|
|
fn route(edge: Id) -> EdgeRoute {
|
|
EdgeRoute {
|
|
edge,
|
|
waypoints: vec![xy(420., 400.), xy(420., 550.)],
|
|
label: Some(EdgeLabel {
|
|
fraction: 0.4,
|
|
offset: xy(3., -12.),
|
|
}),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn own_deleted_allocation_routes_may_disappear_in_every_diagram_only() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let a = f.a;
|
|
let allocation = allocation(&mut f, a);
|
|
for d in [f.d, f.second] {
|
|
f.p.diagrams
|
|
.get_mut(&d)
|
|
.unwrap()
|
|
.routes
|
|
.push(route(allocation));
|
|
}
|
|
let cmd = command(&f, Destination::OutsidePerformers, xy(110., 500.));
|
|
let after = roundtrip(&f.p, cmd);
|
|
for d in [f.d, f.second] {
|
|
let mut expected = f.p.diagrams[&d].clone();
|
|
expected.routes.clear();
|
|
if d == f.d {
|
|
expected.nodes.get_mut(&f.n).unwrap().position = xy(110., 500.);
|
|
}
|
|
assert_eq!(after.diagrams[&d], expected);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unrequested_view_option_cleanup_is_not_whitelisted() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let a = f.a;
|
|
let allocation = allocation(&mut f, a);
|
|
f.p.diagrams
|
|
.get_mut(&f.second)
|
|
.unwrap()
|
|
.view_options
|
|
.hidden_edges
|
|
.push(allocation);
|
|
let changes = collateral(&f);
|
|
assert!(
|
|
changes
|
|
.iter()
|
|
.any(|r| r.id == f.second && r.category == "diagram")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sa_port_and_deferred_exchange_allocation_cascade_refuses_and_retarget_is_invalid() {
|
|
let mut f = fixture(Layer::Sa);
|
|
let a = f.a;
|
|
let work_allocation = allocation(&mut f, a);
|
|
let peer =
|
|
f.p.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
|
.unwrap();
|
|
f.p.relate(RelationKind::Allocation, "Receive allocation", f.b, peer);
|
|
let exchange =
|
|
f.p.relate(RelationKind::FunctionalExchange, "Flow", f.w, peer);
|
|
let (fp1, fp2) = match f.p.relations[&exchange].body {
|
|
RelationBody::FunctionalExchange {
|
|
source_port,
|
|
target_port,
|
|
..
|
|
} => (source_port, target_port),
|
|
_ => panic!(),
|
|
};
|
|
let component = |p: &mut Project, owner: Id, orientation: ComponentPortOrientation| {
|
|
let id = Id::new_v4();
|
|
let port = make_port(
|
|
p,
|
|
id,
|
|
owner,
|
|
"Boundary",
|
|
PortContract::Component {
|
|
kind: ComponentPortKind::Flow,
|
|
orientation,
|
|
},
|
|
)
|
|
.unwrap();
|
|
p.elements.insert(id, port);
|
|
id
|
|
};
|
|
let out = component(&mut f.p, f.a, ComponentPortOrientation::Out);
|
|
let input = component(&mut f.p, f.b, ComponentPortOrientation::In);
|
|
let boundary =
|
|
f.p.relate(RelationKind::BoundaryExchange, "Boundary flow", out, input);
|
|
let port_allocation =
|
|
f.p.relate(RelationKind::PortAllocation, "Output mapping", out, fp1);
|
|
f.p.relate(RelationKind::PortAllocation, "Input mapping", input, fp2);
|
|
let carried = f.p.relate(
|
|
RelationKind::ExchangeAllocation,
|
|
"Carried flow",
|
|
boundary,
|
|
exchange,
|
|
);
|
|
validate_candidate(&f.p).unwrap();
|
|
let actual = prepare_command(&f.p, &Command::Delete(work_allocation))
|
|
.unwrap()
|
|
.project;
|
|
assert!(!actual.relations.contains_key(&port_allocation));
|
|
assert!(!actual.relations.contains_key(&carried));
|
|
let changes = collateral(&f);
|
|
assert!(changes.iter().any(|r| r.id == port_allocation));
|
|
assert!(changes.iter().any(|r| r.id == carried));
|
|
assert!(matches!(
|
|
plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::Performer(f.b),
|
|
&BTreeMap::new()
|
|
),
|
|
Err(Refusal::InvalidCandidate(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn oa_communication_incompleteness_remains_advisory_without_collateral_loss() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let a = f.a;
|
|
allocation(&mut f, a);
|
|
let peer =
|
|
f.p.add_element(Kind::Activity, Layer::Oa, "Peer work", None)
|
|
.unwrap();
|
|
f.p.relate(RelationKind::Allocation, "Peer allocation", f.b, peer);
|
|
let work =
|
|
f.p.relate(RelationKind::Interaction, "Work flow", f.w, peer);
|
|
let mean =
|
|
f.p.relate(RelationKind::CommunicationMean, "Radio", f.a, f.b);
|
|
let carried = f.p.relate(
|
|
RelationKind::ExchangeAllocation,
|
|
"Radio carries work",
|
|
mean,
|
|
work,
|
|
);
|
|
let plan = semantic(&f, Destination::OutsidePerformers);
|
|
assert_eq!(plan.project().relations[&carried], f.p.relations[&carried]);
|
|
assert!(
|
|
plan.warnings
|
|
.iter()
|
|
.any(|i| i.code == "incomplete_exchange_allocation" && i.severity == Severity::Warning)
|
|
);
|
|
let placed = place(&plan, xy(110., 500.), presentation(&f.p, f.d, None)).unwrap();
|
|
roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn scope_detects_first_child_allocation_and_role_transfer_and_finish_rejects_live_edits() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let a = f.a;
|
|
let direct = allocation(&mut f, a);
|
|
let (role, assignment) = role(&mut f, Some(a));
|
|
let original = scope(&f);
|
|
let plan = semantic(&f, Destination::LayoutOnly);
|
|
let placement = place(&plan, xy(110., 500.), presentation(&f.p, f.d, None)).unwrap();
|
|
let child =
|
|
f.p.add_element(Kind::Activity, Layer::Oa, "New first child", Some(f.w))
|
|
.unwrap();
|
|
f.p.relations.get_mut(&direct).unwrap().body = RelationBody::Allocation {
|
|
source: a,
|
|
target: child,
|
|
};
|
|
f.p.relations.get_mut(&assignment).unwrap().body = RelationBody::RoleAssignment {
|
|
source: role,
|
|
target: child,
|
|
};
|
|
validate_candidate(&f.p).unwrap();
|
|
assert!(matches!(
|
|
plan_semantics(&f.p, &original, Destination::LayoutOnly, &BTreeMap::new()),
|
|
Err(Refusal::StaleScope)
|
|
));
|
|
assert!(matches!(
|
|
finish(&plan, &f.p, &placement),
|
|
Err(Refusal::StaleScope)
|
|
));
|
|
// Equal model values after undo are deliberately not an input capability;
|
|
// the App must still reject a retired token using its document/input epoch.
|
|
}
|
|
|
|
#[test]
|
|
fn cache_plan_uses_candidate_index_and_same_pixel_different_target_changes_preview() {
|
|
let f = fixture(Layer::Oa);
|
|
let stable = BTreeMap::from([(f.w, Id::new_v4())]);
|
|
let a = plan_semantics(&f.p, &scope(&f), Destination::Performer(f.a), &stable).unwrap();
|
|
let b = plan_semantics(&f.p, &scope(&f), Destination::Performer(f.b), &stable).unwrap();
|
|
// Same pointer coordinate, different externally resolved target: semantics
|
|
// and grouping must be recomputed even though the snapped delta is equal.
|
|
let first = place(&a, xy(500., 500.), presentation(&f.p, f.d, Some(f.a))).unwrap();
|
|
let second = place(&b, xy(500., 500.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
|
assert_eq!(first.delta, second.delta);
|
|
assert_ne!(
|
|
first
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.id == f.n)
|
|
.unwrap()
|
|
.visual_parent,
|
|
second
|
|
.view
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.id == f.n)
|
|
.unwrap()
|
|
.visual_parent
|
|
);
|
|
assert_eq!(
|
|
first.view,
|
|
projection::project_diagram_with_index(a.project(), &first.diagram, a.index())
|
|
);
|
|
assert_eq!(
|
|
second.view,
|
|
projection::project_diagram_with_index(b.project(), &second.diagram, b.index())
|
|
);
|
|
let index = b.index() as *const ViewIndex;
|
|
let repeated = place(&b, xy(503., 498.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
|
assert_eq!(second.diagram, repeated.diagram);
|
|
assert_eq!(index, b.index() as *const ViewIndex);
|
|
}
|
|
|
|
#[test]
|
|
fn collapsed_target_unfold_is_explicit_and_no_other_fold_changes() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let an = f.p.diagrams[&f.d]
|
|
.nodes
|
|
.values()
|
|
.find(|n| n.element == f.a)
|
|
.unwrap()
|
|
.id;
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&an)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
let plan = semantic(&f, Destination::Performer(f.a));
|
|
let placed = place(&plan, xy(150., 200.), presentation(&f.p, f.d, Some(f.a))).unwrap();
|
|
assert_eq!(placed.unfolded, Some(an));
|
|
assert!(placed.view.nodes.iter().any(|n| n.id == f.n));
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert!(!after.diagrams[&f.d].nodes[&an].collapsed);
|
|
assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]);
|
|
}
|
|
|
|
#[test]
|
|
fn target_presentation_requires_accepted_identity_and_selected_header_visibility() {
|
|
let f = fixture(Layer::Oa);
|
|
let plan = semantic(&f, Destination::Performer(f.a));
|
|
assert!(matches!(
|
|
place(&plan, xy(150., 200.), presentation(&f.p, f.d, Some(f.b))),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
let mut context = presentation(&f.p, f.d, Some(f.a));
|
|
context.target.as_mut().unwrap().placement = f.n;
|
|
assert!(matches!(
|
|
place(&plan, xy(150., 200.), context),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
let mut clipped = presentation(&f.p, f.d, Some(f.a));
|
|
clipped.viewport.position.y = 220.;
|
|
assert!(matches!(
|
|
place(&plan, xy(150., 200.), clipped),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn toolbar_identity_allows_exact_incoming_placement_at_old_label_overlap() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
f.p.elements.get_mut(&f.a).unwrap().name = "Receiver".into();
|
|
let plan = semantic(&f, Destination::Performer(f.a));
|
|
let context = presentation(&f.p, f.d, Some(f.a));
|
|
let before = plan.project().clone();
|
|
let placed = place(&plan, xy(120., 105.), context).unwrap();
|
|
assert_eq!(placed.diagram.nodes[&f.n].position, xy(120., 110.));
|
|
assert_eq!(plan.project(), &before);
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert_eq!(validation::effective_performer(&after, f.w), Some(f.a));
|
|
assert_eq!(after.diagrams[&f.d].nodes[&f.n].position, xy(120., 110.));
|
|
assert_eq!(after.elements, f.p.elements);
|
|
assert_eq!(after.diagrams[&f.second], f.p.diagrams[&f.second]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn selected_header_visibility_allows_viewport_edge_but_refuses_clipping() {
|
|
let f = fixture(Layer::Oa);
|
|
let plan = semantic(&f, Destination::Performer(f.a));
|
|
let mut context = presentation(&f.p, f.d, Some(f.a));
|
|
context.viewport.position.y = 100.;
|
|
for (position, allowed) in [
|
|
(xy(120., 90.), false),
|
|
(xy(120., 100.), true),
|
|
(xy(120., 110.), true),
|
|
] {
|
|
let result = place(&plan, position, context);
|
|
assert_eq!(result.is_ok(), allowed, "{position:?}: {result:?}");
|
|
if let Ok(placed) = result {
|
|
assert_eq!(placed.diagram.nodes[&f.n].position, position);
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn same_performer_readability_preserves_snapped_noop_and_allocation_identity() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let a = f.a;
|
|
allocation(&mut f, a);
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.n)
|
|
.unwrap()
|
|
.position = xy(150., 200.);
|
|
let plan = semantic(&f, Destination::Performer(f.a));
|
|
let context = presentation(&f.p, f.d, Some(f.a));
|
|
assert_eq!(plan.project(), &f.p);
|
|
let noop = place(&plan, xy(154., 200.), context).unwrap();
|
|
assert!(finish(&plan, &f.p, &noop).unwrap().is_none());
|
|
let moved = place(&plan, xy(160., 210.), context).unwrap();
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &moved).unwrap().unwrap());
|
|
assert_eq!(after.relations, f.p.relations);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn geometry_refuses_new_overlap_bounds_and_invisible_headers_and_preserves_existing_overlap() {
|
|
let mut f = fixture(Layer::Oa);
|
|
let obstacle =
|
|
f.p.add_element(Kind::Activity, Layer::Oa, "Other work", None)
|
|
.unwrap();
|
|
f.p.place(f.d, obstacle, xy(450., 500.));
|
|
let plan = semantic(&f, Destination::LayoutOnly);
|
|
assert!(matches!(
|
|
place(&plan, xy(400., 500.), presentation(&f.p, f.d, None)),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
assert!(matches!(
|
|
place(
|
|
&plan,
|
|
xy(MAX_COORDINATE + 10., 500.),
|
|
presentation(&f.p, f.d, None)
|
|
),
|
|
Err(Refusal::InvalidCandidate(_))
|
|
));
|
|
let mut clipped = presentation(&f.p, f.d, None);
|
|
clipped.viewport.size.width = 200.;
|
|
assert!(matches!(
|
|
place(&plan, xy(110., 500.), clipped),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
// Preexisting overlap is tolerated if its positive area is not increased.
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.n)
|
|
.unwrap()
|
|
.position = xy(400., 500.);
|
|
let plan = semantic(&f, Destination::LayoutOnly);
|
|
place(&plan, xy(390., 500.), presentation(&f.p, f.d, None)).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn folded_subtree_cannot_hide_new_collision() {
|
|
let (mut f, _, _, _) = subtree(Layer::Oa);
|
|
let other =
|
|
f.p.add_element(Kind::Activity, Layer::Oa, "Hidden collision", None)
|
|
.unwrap();
|
|
// Expanded moved root will collide with this card, while its folded visible
|
|
// 94-unit card remains far above it. Use Arrange only to isolate geometry.
|
|
f.p.place(f.d, other, xy(550., 530.));
|
|
let plan = semantic(&f, Destination::LayoutOnly);
|
|
assert!(matches!(
|
|
place(&plan, xy(440., 200.), presentation(&f.p, f.d, None)),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_scope_wrong_projection_duplicate_placement_and_stable_id_collision_refuse() {
|
|
let mut f = fixture(Layer::Oa);
|
|
assert!(matches!(
|
|
capture(&f.p, f.second, f.w, f.n),
|
|
Err(Refusal::Ineligible(_))
|
|
));
|
|
assert!(
|
|
matches!(plan_semantics(&f.p, &scope(&f), Destination::Performer(f.a), &BTreeMap::new()), Err(Refusal::MissingAllocationId(id)) if id == f.w)
|
|
);
|
|
assert!(matches!(
|
|
plan_semantics(
|
|
&f.p,
|
|
&scope(&f),
|
|
Destination::Performer(f.a),
|
|
&BTreeMap::from([(f.w, f.a)])
|
|
),
|
|
Err(Refusal::InvalidCandidate(_))
|
|
));
|
|
let mut duplicate = f.p.diagrams[&f.d].nodes[&f.n].clone();
|
|
duplicate.id = Id::new_v4();
|
|
duplicate.alias = "duplicate_placement".into();
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.insert(duplicate.id, duplicate);
|
|
assert!(matches!(
|
|
capture(&f.p, f.d, f.w, f.n),
|
|
Err(Refusal::InvalidCandidate(_))
|
|
));
|
|
}
|
|
|
|
struct DecomposedPair {
|
|
f: Fixture,
|
|
x: Id,
|
|
y: Id,
|
|
xn: Id,
|
|
yn: Id,
|
|
allocation_x: Id,
|
|
}
|
|
|
|
fn decomposed_pair(layer: Layer, parent_position: Position) -> DecomposedPair {
|
|
let mut f = fixture(layer);
|
|
let kind = f.p.elements[&f.w].kind();
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&f.n)
|
|
.unwrap()
|
|
.position = parent_position;
|
|
let x = f.p.add_element(kind, layer, "x", Some(f.w)).unwrap();
|
|
let y = f.p.add_element(kind, layer, "y", Some(f.w)).unwrap();
|
|
let xn = f.p.place(f.d, x, xy(180., 400.));
|
|
let yn = f.p.place(f.d, y, xy(180., 550.));
|
|
f.p.place(f.second, x, xy(1100., 800.));
|
|
f.p.place(f.second, y, xy(1500., 800.));
|
|
let allocation_x =
|
|
f.p.relate(RelationKind::Allocation, "Reviewed x allocation", f.a, x);
|
|
f.p.relations.get_mut(&allocation_x).unwrap().description =
|
|
"Preserve the reviewed allocation identity and rationale".into();
|
|
f.p.relate(RelationKind::Allocation, "Reviewed y allocation", f.a, y);
|
|
let edge_kind = if layer == Layer::Oa {
|
|
RelationKind::Interaction
|
|
} else {
|
|
RelationKind::FunctionalExchange
|
|
};
|
|
let edge = f.p.relate(edge_kind, "x to y", x, y);
|
|
for d in [f.d, f.second] {
|
|
f.p.diagrams.get_mut(&d).unwrap().routes = vec![route(edge)];
|
|
}
|
|
validate_candidate(&f.p).unwrap();
|
|
DecomposedPair {
|
|
f,
|
|
x,
|
|
y,
|
|
xn,
|
|
yn,
|
|
allocation_x,
|
|
}
|
|
}
|
|
|
|
fn projected_node(view: &ProjectedView, id: Id) -> &ProjectedNode {
|
|
view.nodes.iter().find(|node| node.id == id).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn oa_sa_one_leaf_reassignment_and_outward_move_regroup_ancestors_without_moving_sibling() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
// Authored parent placement has room to return to its independent card
|
|
// when the summary stops being uniformly A. Initially projection encloses
|
|
// x/y under P and P under A, even though P's authored position is distant.
|
|
let pair = decomposed_pair(layer, xy(500., 1000.));
|
|
let DecomposedPair {
|
|
f,
|
|
x,
|
|
y,
|
|
xn,
|
|
yn,
|
|
allocation_x,
|
|
} = pair;
|
|
let before = projection::project_view(&f.p, f.d);
|
|
let an = before
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.element == f.a)
|
|
.unwrap()
|
|
.id;
|
|
let bn = before
|
|
.nodes
|
|
.iter()
|
|
.find(|node| node.element == f.b)
|
|
.unwrap()
|
|
.id;
|
|
assert_eq!(validation::allocation_summary(&f.p, f.w), Some(f.a));
|
|
assert_eq!(projected_node(&before, f.n).visual_parent, Some(an));
|
|
assert_eq!(projected_node(&before, xn).visual_parent, Some(f.n));
|
|
assert_eq!(projected_node(&before, yn).visual_parent, Some(f.n));
|
|
let original_sibling = f.p.diagrams[&f.d].nodes[&yn].clone();
|
|
let original_routes = f.p.diagrams[&f.d].routes.clone();
|
|
let other_diagram = f.p.diagrams[&f.second].clone();
|
|
for (destination, requested, visual_parent) in [
|
|
(Destination::Performer(f.b), xy(950., 200.), bn),
|
|
// An unassigned semantic child returns to its work parent; this is
|
|
// outside performers, not removal from its semantic decomposition.
|
|
(Destination::OutsidePerformers, xy(540., 1100.), f.n),
|
|
] {
|
|
let captured = capture(&f.p, f.d, x, xn).unwrap();
|
|
assert_eq!(
|
|
captured.work_records.keys().copied().collect::<Vec<_>>(),
|
|
vec![x]
|
|
);
|
|
assert_eq!(
|
|
captured.placements.keys().copied().collect::<Vec<_>>(),
|
|
vec![xn]
|
|
);
|
|
let plan = plan_semantics(&f.p, &captured, destination, &BTreeMap::new()).unwrap();
|
|
let mut expected = f.p.clone();
|
|
match destination {
|
|
Destination::Performer(performer) => {
|
|
expected.relations.get_mut(&allocation_x).unwrap().body =
|
|
RelationBody::Allocation {
|
|
source: performer,
|
|
target: x,
|
|
};
|
|
}
|
|
Destination::OutsidePerformers => {
|
|
expected.relations.remove(&allocation_x);
|
|
}
|
|
Destination::LayoutOnly => unreachable!(),
|
|
}
|
|
assert_eq!(
|
|
plan.project(),
|
|
&expected,
|
|
"the complete intended semantic diff is one leaf Allocation record"
|
|
);
|
|
let target = match destination {
|
|
Destination::Performer(id) => Some(id),
|
|
_ => None,
|
|
};
|
|
let placed = place(&plan, requested, presentation(&f.p, f.d, target)).unwrap();
|
|
expected
|
|
.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&xn)
|
|
.unwrap()
|
|
.position = requested;
|
|
assert_eq!(placed.diagram, expected.diagrams[&f.d]);
|
|
assert_eq!(projected_node(&placed.view, f.n).visual_parent, None);
|
|
assert_eq!(
|
|
projected_node(&placed.view, xn).visual_parent,
|
|
Some(visual_parent)
|
|
);
|
|
assert_eq!(projected_node(&placed.view, yn).visual_parent, Some(an));
|
|
assert_eq!(validation::allocation_summary(plan.project(), f.w), None);
|
|
let result = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert_eq!(result, expected);
|
|
assert_eq!(result.elements[&x].parent, Some(f.w));
|
|
assert_eq!(result.elements[&y].parent, Some(f.w));
|
|
assert_eq!(result.diagrams[&f.d].nodes[&yn], original_sibling);
|
|
assert_eq!(
|
|
result.diagrams[&f.d].routes, original_routes,
|
|
"one-ended movement retains authored pins and labels"
|
|
);
|
|
assert_eq!(result.diagrams[&f.second], other_diagram);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oa_sa_one_leaf_move_refuses_when_ancestor_regrouping_introduces_unrelated_overlap() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let DecomposedPair { f, x, xn, .. } = decomposed_pair(layer, xy(140., 200.));
|
|
let original = f.p.clone();
|
|
let captured = capture(&f.p, f.d, x, xn).unwrap();
|
|
for (destination, requested) in [
|
|
(Destination::Performer(f.b), xy(950., 200.)),
|
|
(Destination::OutsidePerformers, xy(540., 1100.)),
|
|
] {
|
|
let plan = plan_semantics(&f.p, &captured, destination, &BTreeMap::new()).unwrap();
|
|
validate_candidate(plan.project()).unwrap();
|
|
let mut diagram = plan.project().diagrams[&f.d].clone();
|
|
diagram.nodes.get_mut(&xn).unwrap().position = requested;
|
|
let actual_projection =
|
|
projection::project_diagram_with_index(plan.project(), &diagram, plan.index());
|
|
let parent = projected_node(&actual_projection, f.n);
|
|
let a = actual_projection
|
|
.nodes
|
|
.iter()
|
|
.find(|n| n.element == f.a)
|
|
.unwrap();
|
|
assert_eq!(parent.visual_parent, None);
|
|
assert!(
|
|
parent.position.x < a.position.x + a.size.width
|
|
&& a.position.x < parent.position.x + parent.size.width
|
|
);
|
|
assert!(
|
|
parent.position.y < a.position.y + a.size.height
|
|
&& a.position.y < parent.position.y + parent.size.height
|
|
);
|
|
let target = match destination {
|
|
Destination::Performer(id) => Some(id),
|
|
_ => None,
|
|
};
|
|
assert!(matches!(
|
|
place(&plan, requested, presentation(&f.p, f.d, target)),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
assert_eq!(
|
|
f.p, original,
|
|
"a valid semantic split still needs readable ancestor geometry"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
struct PopulatedTarget {
|
|
f: Fixture,
|
|
target: Id,
|
|
group: Id,
|
|
nested: Id,
|
|
peer: Id,
|
|
}
|
|
|
|
fn populated_target(layer: Layer, hidden_overlap: bool) -> PopulatedTarget {
|
|
let mut f = fixture(layer);
|
|
let kind = f.p.elements[&f.w].kind();
|
|
let group =
|
|
f.p.add_element(kind, layer, "Existing collapsed work", None)
|
|
.unwrap();
|
|
let nested =
|
|
f.p.add_element(kind, layer, "Existing hidden child", Some(group))
|
|
.unwrap();
|
|
let peer =
|
|
f.p.add_element(kind, layer, "Existing peer work", None)
|
|
.unwrap();
|
|
f.p.relate(
|
|
RelationKind::Allocation,
|
|
"Nested responsibility",
|
|
f.b,
|
|
nested,
|
|
);
|
|
f.p.relate(RelationKind::Allocation, "Peer responsibility", f.b, peer);
|
|
let group = f.p.place(f.d, group, xy(950., 300.));
|
|
let nested = f.p.place(f.d, nested, xy(990., 450.));
|
|
let peer = f.p.place(
|
|
f.d,
|
|
peer,
|
|
xy(if hidden_overlap { 1050. } else { 1300. }, 300.),
|
|
);
|
|
let target = f.p.diagrams[&f.d]
|
|
.nodes
|
|
.values()
|
|
.find(|n| n.element == f.b)
|
|
.unwrap()
|
|
.id;
|
|
for id in [target, group] {
|
|
f.p.diagrams
|
|
.get_mut(&f.d)
|
|
.unwrap()
|
|
.nodes
|
|
.get_mut(&id)
|
|
.unwrap()
|
|
.collapsed = true;
|
|
}
|
|
validate_candidate(&f.p).unwrap();
|
|
PopulatedTarget {
|
|
f,
|
|
target,
|
|
group,
|
|
nested,
|
|
peer,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oa_sa_populated_collapsed_target_accepts_room_and_refuses_collision_without_relocating_contents()
|
|
{
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let PopulatedTarget {
|
|
f,
|
|
target,
|
|
group,
|
|
nested,
|
|
peer,
|
|
} = populated_target(layer, false);
|
|
let original = f.p.clone();
|
|
let plan = semantic(&f, Destination::Performer(f.b));
|
|
let frozen = presentation(&f.p, f.d, Some(f.b));
|
|
let placed = place(&plan, xy(950., 200.), frozen).unwrap();
|
|
assert_eq!(placed.unfolded, Some(target));
|
|
for id in [group, nested, peer] {
|
|
assert_eq!(
|
|
placed.diagram.nodes[&id],
|
|
original.diagrams[&f.d].nodes[&id]
|
|
);
|
|
}
|
|
assert!(
|
|
projected_node(&placed.view, group).collapsed,
|
|
"opening the target does not open nested collapsed work"
|
|
);
|
|
assert!(placed.view.nodes.iter().all(|n| n.id != nested));
|
|
assert_eq!(
|
|
projected_node(&placed.view, f.n).visual_parent,
|
|
Some(target)
|
|
);
|
|
let mut expected = plan.project().clone();
|
|
let diagram = expected.diagrams.get_mut(&f.d).unwrap();
|
|
diagram.nodes.get_mut(&f.n).unwrap().position = xy(950., 200.);
|
|
diagram.nodes.get_mut(&target).unwrap().collapsed = false;
|
|
let after = roundtrip(&f.p, finish(&plan, &f.p, &placed).unwrap().unwrap());
|
|
assert_eq!(after, expected);
|
|
assert_eq!(after.diagrams[&f.second], original.diagrams[&f.second]);
|
|
// Same destination and baseline, but the moving card covers existing
|
|
// content. The planner cannot quietly pack or relocate that content.
|
|
assert!(matches!(
|
|
place(&plan, xy(950., 300.), frozen),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
assert_eq!(f.p, original);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oa_sa_unfolding_prior_hidden_overlap_refuses_even_when_insertion_does_not_collide() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let PopulatedTarget {
|
|
f,
|
|
target,
|
|
group,
|
|
nested,
|
|
peer,
|
|
} = populated_target(layer, true);
|
|
let original = f.p.clone();
|
|
let before = projection::project_view(&f.p, f.d);
|
|
assert!(
|
|
before
|
|
.nodes
|
|
.iter()
|
|
.all(|n| ![group, nested, peer].contains(&n.id))
|
|
);
|
|
// This confusing pair exists in saved geometry already. Merely opening
|
|
// B exposes it: neither existing placement has been relocated by a drag.
|
|
let mut unfolded = f.p.diagrams[&f.d].clone();
|
|
unfolded.nodes.get_mut(&target).unwrap().collapsed = false;
|
|
let opening_only = projection::project_diagram(&f.p, &unfolded);
|
|
let existing_group = projected_node(&opening_only, group);
|
|
let existing_peer = projected_node(&opening_only, peer);
|
|
assert!(existing_group.position.x + existing_group.size.width > existing_peer.position.x);
|
|
assert_eq!(existing_group.position.y, existing_peer.position.y);
|
|
assert!(
|
|
opening_only.nodes.iter().all(|n| n.id != nested),
|
|
"nested fold remains closed"
|
|
);
|
|
let plan = semantic(&f, Destination::Performer(f.b));
|
|
unfolded.nodes.get_mut(&f.n).unwrap().position = xy(950., 200.);
|
|
let insertion =
|
|
projection::project_diagram_with_index(plan.project(), &unfolded, plan.index());
|
|
assert_eq!(projected_node(&insertion, group), existing_group);
|
|
assert_eq!(projected_node(&insertion, peer), existing_peer);
|
|
let moving = projected_node(&insertion, f.n);
|
|
assert!(
|
|
moving.position.y + moving.size.height <= existing_group.position.y,
|
|
"the incoming work itself has room"
|
|
);
|
|
assert!(matches!(
|
|
place(&plan, xy(950., 200.), presentation(&f.p, f.d, Some(f.b))),
|
|
Err(Refusal::LayoutRequired(_))
|
|
));
|
|
assert_eq!(
|
|
f.p, original,
|
|
"first-slice policy refuses newly exposed confusion, not only relocation collisions"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oa_sa_state_owned_action_and_availability_reject_reassignment_atomically() {
|
|
for layer in [Layer::Oa, Layer::Sa] {
|
|
let mut f = fixture(layer);
|
|
let a = f.a;
|
|
let direct = allocation(&mut f, a);
|
|
let machine =
|
|
f.p.add_element(Kind::StateMachine, layer, "A lifecycle", Some(a))
|
|
.unwrap();
|
|
let region =
|
|
f.p.add_element(Kind::Region, layer, "Main region", Some(machine))
|
|
.unwrap();
|
|
let initial =
|
|
f.p.add_element(Kind::Initial, layer, "Initial", Some(region))
|
|
.unwrap();
|
|
let ready =
|
|
f.p.add_element(Kind::State, layer, "Ready", Some(region))
|
|
.unwrap();
|
|
let final_state =
|
|
f.p.add_element(Kind::Final, layer, "Finished", Some(region))
|
|
.unwrap();
|
|
f.p.relate(RelationKind::StateTransition, "Enter ready", initial, ready);
|
|
f.p.relate(RelationKind::StateTransition, "Finish", ready, final_state);
|
|
let action =
|
|
f.p.relate(RelationKind::StateAction, "A entry action", ready, f.w);
|
|
let availability =
|
|
f.p.relate(RelationKind::Availability, "A availability", f.w, ready);
|
|
validate_candidate(&f.p).unwrap();
|
|
let mut bundle = format_bundle(&f.p);
|
|
bundle
|
|
.files
|
|
.get_mut(&bundle.entry.clone())
|
|
.unwrap()
|
|
.insert_str(
|
|
0,
|
|
"// Keep state responsibility and accepted source intact.\n",
|
|
);
|
|
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
|
document
|
|
.execute(Command::Describe(
|
|
f.w,
|
|
"Accepted engineering rationale".into(),
|
|
))
|
|
.unwrap();
|
|
document
|
|
.execute(Command::Rename(f.w, "Future reviewed work".into()))
|
|
.unwrap();
|
|
assert!(document.undo());
|
|
assert!(document.can_undo() && document.can_redo());
|
|
let accepted = document.project().clone();
|
|
let source = document.bundle().clone();
|
|
let revision = document.revision();
|
|
let mut changed = accepted.relations[&direct].clone();
|
|
changed.body = RelationBody::Allocation {
|
|
source: f.b,
|
|
target: f.w,
|
|
};
|
|
let mut intended = accepted.clone();
|
|
intended.relations.insert(direct, changed.clone());
|
|
let errors: Vec<_> = validation::validate(&intended)
|
|
.into_iter()
|
|
.filter(|i| i.severity == Severity::Error)
|
|
.collect();
|
|
for context in [action, availability] {
|
|
assert!(
|
|
errors
|
|
.iter()
|
|
.any(|i| i.element == Some(context) && i.code == "state_owner_context"),
|
|
"actual core validator must reject the cross-performer behavior: {errors:?}"
|
|
);
|
|
}
|
|
let captured = capture(&accepted, f.d, f.w, f.n).unwrap();
|
|
assert!(
|
|
matches!(plan_semantics(&accepted, &captured, Destination::Performer(f.b), &BTreeMap::new()), Err(Refusal::InvalidCandidate(reason)) if reason.contains("state_owner_context"))
|
|
);
|
|
// Exercise actual Document admission too: its model preparation rejects
|
|
// the whole semantic-plus-layout batch before source or history changes.
|
|
let mut moved = accepted.diagrams[&f.d].nodes[&f.n].clone();
|
|
moved.position = xy(950., 200.);
|
|
let error = document
|
|
.execute(Command::Batch(vec![
|
|
Command::EditRelation(direct, changed),
|
|
Command::EditNode {
|
|
diagram: f.d,
|
|
original: f.n,
|
|
node: moved,
|
|
},
|
|
]))
|
|
.unwrap_err();
|
|
assert!(
|
|
matches!(error, crate::document::DocumentError::Model(ref reason) if reason.to_string().contains("state_owner_context"))
|
|
);
|
|
assert_eq!(document.project(), &accepted);
|
|
assert_eq!(document.bundle(), &source);
|
|
assert_eq!(document.revision(), revision);
|
|
assert!(document.can_undo() && document.can_redo());
|
|
assert!(document.redo());
|
|
assert_eq!(
|
|
document.project().elements[&f.w].name,
|
|
"Future reviewed work"
|
|
);
|
|
assert!(document.undo());
|
|
assert_eq!(document.project(), &accepted);
|
|
assert_eq!(document.bundle(), &source);
|
|
// Core intentionally permits missing allocation here. Do not turn the
|
|
// A-to-B context error into an invented prohibition of outward removal.
|
|
let outward = plan_semantics(
|
|
&accepted,
|
|
&captured,
|
|
Destination::OutsidePerformers,
|
|
&BTreeMap::new(),
|
|
)
|
|
.unwrap();
|
|
validate_candidate(outward.project()).unwrap();
|
|
assert_eq!(
|
|
outward.project().relations[&action],
|
|
accepted.relations[&action]
|
|
);
|
|
assert_eq!(
|
|
outward.project().relations[&availability],
|
|
accepted.relations[&availability]
|
|
);
|
|
}
|
|
}
|