needs: also_moved_by - a need carried by more than one group's desk
Test / test (push) Successful in 27s

Roles overlap on one aggregate: a project manager prices a change, a
site manager completes it. A need may list the other groups whose
desks hold some of its moves; their buttons count toward reaching
`done_when` and toward the strand check exactly as `handled_by`'s do.
`handled_by` must still hold a desk of its own, so that someone is
answerable for the record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-21 21:46:11 +02:00
co-authored by Claude Fable 5.1
parent 6efd7a0c6b
commit d7f60eaa31
+34 -2
View File
@@ -86,6 +86,13 @@ pub struct Need {
/// becoming open -> quoted) fails instead of passing. /// becoming open -> quoted) fails instead of passing.
#[serde(default)] #[serde(default)]
pub stages: Vec<String>, pub stages: Vec<String>,
/// Other groups whose desks carry some of this need's moves: a
/// site manager closing what a project manager opened. Their
/// buttons count toward reaching `done_when` exactly as
/// `handled_by`'s do. `handled_by` must still hold a desk of its
/// own - someone has to be answerable for the record.
#[serde(default)]
pub also_moved_by: Vec<String>,
} }
fn root() -> String { fn root() -> String {
@@ -392,9 +399,11 @@ pub fn check(
let desks = desks_over(questions, &need.lands_in); let desks = desks_over(questions, &need.lands_in);
let own: Vec<&Desk> = desks let own: Vec<&Desk> = desks
.iter() .iter()
.filter(|d| d.group.as_deref() == Some(need.handled_by.as_str())) .filter(|d| {
d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g))
})
.collect(); .collect();
if own.is_empty() { if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) {
let others: Vec<String> = desks let others: Vec<String> = desks
.iter() .iter()
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
@@ -883,6 +892,29 @@ mod tests {
); );
} }
#[test]
fn a_need_can_be_carried_by_more_than_one_groups_desk() {
// owners may only quote or lose; closing a quoted job is the
// site managers' button, on a desk of their own.
let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
let more = build_questions(&[(
"site/index.yaml".to_string(),
"name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(),
)])
.unwrap();
questions.extend(more);
let mut file = needs("");
// Alone, owners cannot finish it...
assert_eq!(fails(&check(&file, &questions, &aggregates)).len(), 2);
// ...with the site managers' buttons counted, they can.
file.needs[0].also_moved_by = vec!["site_managers".to_string()];
assert!(check(&file, &questions, &aggregates).is_empty());
// But someone has to be answerable: handled_by needs its own desk.
file.needs[0].handled_by = "nobody".to_string();
assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\""));
}
#[test] #[test]
fn the_wrong_group_holding_the_desk_is_a_failure() { fn the_wrong_group_holding_the_desk_is_a_failure() {
let (questions, aggregates) = site(FULL_DESK); let (questions, aggregates) = site(FULL_DESK);