Plan Allocation work-group assignments as one exact transaction
This commit is contained in:
@@ -0,0 +1,497 @@
|
||||
use super::*;
|
||||
|
||||
struct Composite {
|
||||
f: Fixture,
|
||||
child: Id,
|
||||
child_node: Id,
|
||||
unplaced: Id,
|
||||
}
|
||||
|
||||
fn composite(layer: Layer) -> Composite {
|
||||
let mut f = fixture(layer);
|
||||
let kind = f.p.elements[&f.w].kind();
|
||||
let child =
|
||||
f.p.add_element(kind, layer, "Placed child", Some(f.w))
|
||||
.unwrap();
|
||||
let unplaced =
|
||||
f.p.add_element(kind, layer, "Unplaced leaf", Some(f.w))
|
||||
.unwrap();
|
||||
let child_node = f.p.place(f.d, child, xy(140., 600.));
|
||||
validate_candidate(&f.p).unwrap();
|
||||
Composite {
|
||||
f,
|
||||
child,
|
||||
child_node,
|
||||
unplaced,
|
||||
}
|
||||
}
|
||||
|
||||
fn expected_allocation(id: Id, alias: &str, name: &str, performer: Id, work: Id) -> Relation {
|
||||
Relation {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: work,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_checks_original_identity_and_visibility_before_normalization() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let c = composite(layer);
|
||||
let valid = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
|
||||
let admitted = assignment::capture(&c.f.p, c.f.d, &valid, c.f.n).unwrap();
|
||||
assert_eq!(admitted.selected(), &valid);
|
||||
let foreign = c.f.p.diagrams[&c.f.second]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == c.f.w)
|
||||
.unwrap()
|
||||
.id;
|
||||
// Each extra member is semantically covered by the valid selected parent.
|
||||
// Normalizing first would incorrectly hide its invalid original identity.
|
||||
for invalid in [
|
||||
BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.unplaced)]),
|
||||
BTreeMap::from([(c.f.n, c.f.w), (Id::new_v4(), c.child)]),
|
||||
BTreeMap::from([(c.f.n, c.f.w), (foreign, c.f.w)]),
|
||||
] {
|
||||
assert!(matches!(
|
||||
assignment::capture(&c.f.p, c.f.d, &invalid, c.f.n),
|
||||
Err(Refusal::StaleScope)
|
||||
));
|
||||
}
|
||||
let mut folded = c.f.p.clone();
|
||||
folded
|
||||
.diagrams
|
||||
.get_mut(&c.f.d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&c.f.n)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
validate_candidate(&folded).unwrap();
|
||||
assert!(
|
||||
!projection::project_view(&folded, c.f.d)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == c.child_node)
|
||||
);
|
||||
// Hidden descendants remain in Q when only the visible parent is selected.
|
||||
let parent_only =
|
||||
assignment::capture(&folded, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n).unwrap();
|
||||
assert!(parent_only.affected().contains_key(&c.child_node));
|
||||
assert!(matches!(
|
||||
assignment::capture(&folded, c.f.d, &valid, c.f.n),
|
||||
Err(Refusal::StaleScope)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_normalizes_parent_child_and_assigns_unplaced_leaf_once() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let c = composite(layer);
|
||||
let selected = BTreeMap::from([(c.f.n, c.f.w), (c.child_node, c.child)]);
|
||||
let scope = assignment::capture(&c.f.p, c.f.d, &selected, c.f.n).unwrap();
|
||||
assert_eq!(scope.selected(), &selected);
|
||||
assert_eq!(
|
||||
scope.affected().keys().copied().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([c.f.n, c.child_node])
|
||||
);
|
||||
assert_eq!(
|
||||
scope.leaves().keys().copied().collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([c.child, c.unplaced])
|
||||
);
|
||||
let child_allocation = Id::new_v4();
|
||||
let unplaced_allocation = Id::new_v4();
|
||||
let ids = BTreeMap::from([
|
||||
(c.child, child_allocation),
|
||||
(c.unplaced, unplaced_allocation),
|
||||
]);
|
||||
let plan = assignment::plan_semantics(&scope, Destination::Performer(c.f.a), &ids).unwrap();
|
||||
assert_eq!(plan.changed_count(), 2);
|
||||
let mut expected = c.f.p.clone();
|
||||
expected.relations.insert(
|
||||
child_allocation,
|
||||
expected_allocation(
|
||||
child_allocation,
|
||||
"a_performs_placed_child",
|
||||
"A performs Placed child",
|
||||
c.f.a,
|
||||
c.child,
|
||||
),
|
||||
);
|
||||
expected.relations.insert(
|
||||
unplaced_allocation,
|
||||
expected_allocation(
|
||||
unplaced_allocation,
|
||||
"a_performs_unplaced_leaf",
|
||||
"A performs Unplaced leaf",
|
||||
c.f.a,
|
||||
c.unplaced,
|
||||
),
|
||||
);
|
||||
assert_eq!(plan.project(), &expected);
|
||||
let placed = assignment::place(
|
||||
&plan,
|
||||
xy(140., 200.),
|
||||
presentation(&c.f.p, c.f.d, Some(c.f.a)),
|
||||
)
|
||||
.unwrap();
|
||||
let mut clipped = presentation(&c.f.p, c.f.d, Some(c.f.a));
|
||||
clipped.viewport.size.height = 450.; // bottom350: parent header fits; selected child ends362.
|
||||
for (id, should_fit) in [(c.f.n, true), (c.child_node, false)] {
|
||||
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
|
||||
let header = Rect {
|
||||
position: node.position,
|
||||
size: Size {
|
||||
width: node.size.width,
|
||||
height: 62.,
|
||||
},
|
||||
};
|
||||
assert_eq!(clipped.viewport.contains(header), should_fit);
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
assignment::place(&plan, xy(140., 200.), clipped),
|
||||
Err(Refusal::LayoutRequired(_))
|
||||
),
|
||||
"normalized-away selected child still requires its header"
|
||||
);
|
||||
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
|
||||
d.nodes.get_mut(&c.f.n).unwrap().position = xy(140., 200.);
|
||||
d.nodes.get_mut(&c.child_node).unwrap().position = xy(180., 300.);
|
||||
assert_eq!(placed.delta, xy(40., -300.));
|
||||
assert_eq!(
|
||||
roundtrip(
|
||||
&c.f.p,
|
||||
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
|
||||
),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
expected.diagrams[&c.f.d].nodes.len(),
|
||||
c.f.p.diagrams[&c.f.d].nodes.len()
|
||||
);
|
||||
assert!(
|
||||
!expected.diagrams[&c.f.d]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.element == c.unplaced)
|
||||
);
|
||||
assert!(
|
||||
!expected
|
||||
.relations
|
||||
.values()
|
||||
.any(|relation| relation.kind() == RelationKind::Allocation
|
||||
&& relation.target() == c.f.w)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_distributed_parent_refuses_but_its_independent_leaves_can_reassign() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut c = composite(layer);
|
||||
let second_node = c.f.p.place(c.f.d, c.unplaced, xy(940., 200.));
|
||||
let performer =
|
||||
c.f.p
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Actor
|
||||
} else {
|
||||
Kind::SystemActor
|
||||
},
|
||||
layer,
|
||||
"C",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
c.f.p.place(c.f.d, performer, xy(1700., 100.));
|
||||
let d = c.f.p.diagrams.get_mut(&c.f.d).unwrap();
|
||||
d.nodes.get_mut(&c.f.n).unwrap().position = xy(1700., 800.);
|
||||
d.nodes.get_mut(&c.child_node).unwrap().position = xy(140., 200.);
|
||||
let ax =
|
||||
c.f.p
|
||||
.relate(RelationKind::Allocation, "A child", c.f.a, c.child);
|
||||
let by =
|
||||
c.f.p
|
||||
.relate(RelationKind::Allocation, "B child", c.f.b, c.unplaced);
|
||||
validate_candidate(&c.f.p).unwrap();
|
||||
let visible = projection::project_view(&c.f.p, c.f.d);
|
||||
for id in [c.f.n, c.child_node, second_node] {
|
||||
assert!(visible.nodes.iter().any(|node| node.id == id));
|
||||
}
|
||||
assert_eq!(scope(&c.f).uniform, Uniform::MixedOrUnresolved);
|
||||
assert!(matches!(
|
||||
assignment::capture(&c.f.p, c.f.d, &BTreeMap::from([(c.f.n, c.f.w)]), c.f.n,),
|
||||
Err(Refusal::MixedLayoutRequired)
|
||||
));
|
||||
let selected = BTreeMap::from([(c.child_node, c.child), (second_node, c.unplaced)]);
|
||||
let captured = assignment::capture(&c.f.p, c.f.d, &selected, c.child_node).unwrap();
|
||||
let plan = assignment::plan_semantics(
|
||||
&captured,
|
||||
Destination::Performer(performer),
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut expected = c.f.p.clone();
|
||||
expected.relations.get_mut(&ax).unwrap().body = RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: c.child,
|
||||
};
|
||||
expected.relations.get_mut(&by).unwrap().body = RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: c.unplaced,
|
||||
};
|
||||
assert_eq!(plan.project(), &expected);
|
||||
// One delta moves both leaves. The unselected semantic parent remains
|
||||
// authored at (1700,800), while its derived enclosure may change.
|
||||
let placed = assignment::place(
|
||||
&plan,
|
||||
xy(1740., 120.),
|
||||
presentation(&c.f.p, c.f.d, Some(performer)),
|
||||
)
|
||||
.unwrap();
|
||||
let d = expected.diagrams.get_mut(&c.f.d).unwrap();
|
||||
d.nodes.get_mut(&c.child_node).unwrap().position = xy(1740., 120.);
|
||||
d.nodes.get_mut(&second_node).unwrap().position = xy(2540., 120.);
|
||||
assert_eq!(
|
||||
roundtrip(
|
||||
&c.f.p,
|
||||
assignment::finish(&plan, &c.f.p, &placed).unwrap().unwrap()
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn separate_unassigned(layer: Layer) -> (Fixture, Id, Id) {
|
||||
let mut f = fixture(layer);
|
||||
let companion =
|
||||
f.p.add_element(f.p.elements[&f.w].kind(), layer, "Companion", None)
|
||||
.unwrap();
|
||||
let companion_node = f.p.place(f.d, companion, xy(100., 1000.));
|
||||
validate_candidate(&f.p).unwrap();
|
||||
(f, companion, companion_node)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_finish_rejects_stale_project_and_each_tampered_result() {
|
||||
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
|
||||
let selected = BTreeMap::from([(f.n, f.w), (companion_node, companion)]);
|
||||
let captured = assignment::capture(&f.p, f.d, &selected, f.n).unwrap();
|
||||
let plan =
|
||||
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
|
||||
.unwrap();
|
||||
let context = presentation(&f.p, f.d, None);
|
||||
let valid = assignment::place(&plan, xy(500., 500.), context).unwrap();
|
||||
let mut expected = f.p.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.n)
|
||||
.unwrap()
|
||||
.position = xy(500., 500.);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&companion_node)
|
||||
.unwrap()
|
||||
.position = xy(500., 1000.);
|
||||
assert_eq!(
|
||||
roundtrip(
|
||||
&f.p,
|
||||
assignment::finish(&plan, &f.p, &valid).unwrap().unwrap()
|
||||
),
|
||||
expected
|
||||
);
|
||||
let mut stale = f.p.clone();
|
||||
stale.elements.get_mut(&f.b).unwrap().description = "Changed unrelated accepted record".into();
|
||||
validate_candidate(&stale).unwrap();
|
||||
assert!(matches!(
|
||||
assignment::finish(&plan, &stale, &valid),
|
||||
Err(Refusal::StaleScope)
|
||||
));
|
||||
for field in 0..6 {
|
||||
let mut altered = assignment::place(&plan, xy(500., 500.), context).unwrap();
|
||||
match field {
|
||||
0 => altered.diagram.nodes.get_mut(&f.n).unwrap().position.x += 20.,
|
||||
1 => {
|
||||
altered
|
||||
.view
|
||||
.nodes
|
||||
.iter_mut()
|
||||
.find(|node| node.id == f.n)
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 20.
|
||||
}
|
||||
2 => altered.delta.x += 20.,
|
||||
3 => altered.unfolded = Some(f.n),
|
||||
4 => altered.commands.clear(),
|
||||
5 => altered.requested.x += 10.,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert!(
|
||||
matches!(
|
||||
assignment::finish(&plan, &f.p, &altered),
|
||||
Err(Refusal::StaleScope)
|
||||
),
|
||||
"tampered result field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_requires_non_anchor_selected_header_inside_viewport() {
|
||||
let (f, companion, companion_node) = separate_unassigned(Layer::Oa);
|
||||
let captured = assignment::capture(
|
||||
&f.p,
|
||||
f.d,
|
||||
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
|
||||
f.n,
|
||||
)
|
||||
.unwrap();
|
||||
let plan =
|
||||
assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
|
||||
.unwrap();
|
||||
let wide = presentation(&f.p, f.d, None);
|
||||
let placed = assignment::place(&plan, xy(500., 500.), wide).unwrap();
|
||||
let mut narrow = wide;
|
||||
narrow.viewport.size.height = 800.; // world bottom700: anchor header fits, companion does not.
|
||||
let header = |id| {
|
||||
let node = placed.view.nodes.iter().find(|node| node.id == id).unwrap();
|
||||
Rect {
|
||||
position: node.position,
|
||||
size: Size {
|
||||
width: node.size.width,
|
||||
height: 62.,
|
||||
},
|
||||
}
|
||||
};
|
||||
assert!(narrow.viewport.contains(header(f.n)));
|
||||
assert!(!narrow.viewport.contains(header(companion_node)));
|
||||
// Only the viewport differs from the already admitted identical geometry.
|
||||
assert!(matches!(
|
||||
assignment::place(&plan, xy(500., 500.), narrow),
|
||||
Err(Refusal::LayoutRequired(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_assignment_unresolved_oa_role_leaf_accepts_explicit_target_but_not_outside() {
|
||||
for ambiguous in [false, true] {
|
||||
let (mut f, companion, companion_node) = separate_unassigned(Layer::Oa);
|
||||
let (role_id, role_assignment) = role(&mut f, None);
|
||||
if ambiguous {
|
||||
f.p.relate(
|
||||
RelationKind::RoleRealization,
|
||||
"A realizes responsibility",
|
||||
f.a,
|
||||
role_id,
|
||||
);
|
||||
f.p.relate(
|
||||
RelationKind::RoleRealization,
|
||||
"B realizes responsibility",
|
||||
f.b,
|
||||
role_id,
|
||||
);
|
||||
}
|
||||
validate_candidate(&f.p).unwrap();
|
||||
assert_eq!(validation::effective_performer(&f.p, f.w), None);
|
||||
let captured = assignment::capture(
|
||||
&f.p,
|
||||
f.d,
|
||||
&BTreeMap::from([(f.n, f.w), (companion_node, companion)]),
|
||||
f.n,
|
||||
)
|
||||
.unwrap();
|
||||
let Refusal::RoleExitRequired {
|
||||
work,
|
||||
roles,
|
||||
performers,
|
||||
..
|
||||
} = assignment::plan_semantics(&captured, Destination::OutsidePerformers, &BTreeMap::new())
|
||||
.unwrap_err()
|
||||
else {
|
||||
panic!("unresolved role must produce its specific outward refusal");
|
||||
};
|
||||
assert_eq!(work, f.w);
|
||||
assert_eq!(roles, vec![role_id]);
|
||||
assert_eq!(
|
||||
performers.into_iter().collect::<BTreeSet<_>>(),
|
||||
if ambiguous {
|
||||
BTreeSet::from([f.a, f.b])
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
}
|
||||
);
|
||||
let work_allocation = Id::new_v4();
|
||||
let companion_allocation = Id::new_v4();
|
||||
let ids = BTreeMap::from([(f.w, work_allocation), (companion, companion_allocation)]);
|
||||
let plan =
|
||||
assignment::plan_semantics(&captured, Destination::Performer(f.b), &ids).unwrap();
|
||||
assert_eq!(plan.changed_count(), 2);
|
||||
assert!(plan.label().contains("role responsibility retained"));
|
||||
let mut expected = f.p.clone();
|
||||
expected.relations.insert(
|
||||
work_allocation,
|
||||
expected_allocation(
|
||||
work_allocation,
|
||||
"b_performs_work",
|
||||
"B performs Work",
|
||||
f.b,
|
||||
f.w,
|
||||
),
|
||||
);
|
||||
expected.relations.insert(
|
||||
companion_allocation,
|
||||
expected_allocation(
|
||||
companion_allocation,
|
||||
"b_performs_companion",
|
||||
"B performs Companion",
|
||||
f.b,
|
||||
companion,
|
||||
),
|
||||
);
|
||||
assert_eq!(plan.project(), &expected);
|
||||
assert_eq!(
|
||||
plan.project().relations[&role_assignment],
|
||||
f.p.relations[&role_assignment]
|
||||
);
|
||||
let placed =
|
||||
assignment::place(&plan, xy(940., 200.), presentation(&f.p, f.d, Some(f.b))).unwrap();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.n)
|
||||
.unwrap()
|
||||
.position = xy(940., 200.);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&companion_node)
|
||||
.unwrap()
|
||||
.position = xy(940., 700.);
|
||||
assert_eq!(
|
||||
roundtrip(
|
||||
&f.p,
|
||||
assignment::finish(&plan, &f.p, &placed).unwrap().unwrap()
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user