diff --git a/docs/lint.md b/docs/lint.md index faf47e7..39c860e 100644 --- a/docs/lint.md +++ b/docs/lint.md @@ -34,7 +34,21 @@ in `needs.yaml`, when it has one: - the handling group holds a desk over that bucket, and every finished state is reachable through the buttons it (and any `also_moved_by` group) offers; no record strands on the way; -- every stage the business named is a state of the machine. +- every stage the business named is a state of the machine, and each + working stage can follow the one before it; +- across each bucket, whichever need brought a record in: every state + is somewhere a record can get to, no state a record gets to holds it + with no way on (before done or after), every declared move is a + button, a visitor's own link or a clock (a move nobody offers warns), + no desk is gated to one group on the page and another on its list, + and every mail goes out from a state records reach and names only + fields the forms collect. + +Every finding says what to change and where: the missing +`{ from, to, label }` and the desk page it goes on, the group that +already has the button, or the edge aggregates.yaml lacks. A schema +error no longer hides the needs findings - both are printed, so one +round can fix them together. A need marked `planned` warns instead of failing. Exit status is the verdict, so a content repo's CI is one line. diff --git a/src/lint/check.rs b/src/lint/check.rs index 31e90f2..0030ac6 100644 --- a/src/lint/check.rs +++ b/src/lint/check.rs @@ -126,11 +126,33 @@ pub async fn run(argv: Vec) -> anyhow::Result<()> { } Err(e) => { eprintln!("FAIL: {e}"); + // One schema error is no reason to hide the rest: the needs + // pass reads the same content and says what else is wrong, + // so a fix round can take them all at once. + if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) { + print_findings(&needs::check(&file, &questions, &aggregates_map)); + } std::process::exit(1); } } } +/// Each finding on its own line, FAIL or WARN first; the count of FAILs. +fn print_findings(findings: &[needs::Finding]) -> usize { + let mut failed = 0; + for finding in findings { + let level = match finding.level { + needs::Level::Fail => { + failed += 1; + "FAIL" + } + needs::Level::Warn => "WARN", + }; + eprintln!("{level}: {} {:?}: {}", finding.scope, finding.need, finding.message); + } + failed +} + /// The business-needs pass (see `crate::lint::needs`): structure always, /// then the wording tasks written out and/or an engine's answers /// scored, when asked. A repo without `needs.yaml` skips all of it. @@ -158,18 +180,7 @@ fn check_needs( std::process::exit(1); } }; - let findings = needs::check(&file, questions, aggregates_map); - let mut failed = 0; - for finding in &findings { - let level = match finding.level { - needs::Level::Fail => { - failed += 1; - "FAIL" - } - needs::Level::Warn => "WARN", - }; - eprintln!("{level}: need {:?}: {}", finding.need, finding.message); - } + let failed = print_findings(&needs::check(&file, questions, aggregates_map)); for need in &file.needs { if let Some(path) = needs::path_for(questions, need) { let steps: Vec = path diff --git a/src/lint/needs.rs b/src/lint/needs.rs index d5aacbd..9d0f016 100644 --- a/src/lint/needs.rs +++ b/src/lint/needs.rs @@ -137,9 +137,13 @@ pub enum Level { #[derive(Debug)] pub struct Finding { + /// The need's id, or the bucket's name when `scope` is "bucket". pub need: String, pub level: Level, pub message: String, + /// "need" for a need's own path; "bucket" for what holds for every + /// record in a bucket whichever need brought it in. + pub scope: &'static str, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -255,6 +259,10 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option> { struct Desk { page: String, group: Option, + /// The page's own `qualifies`, kept apart from the resource's + /// `requires_group`: when both are set and differ, only members of + /// both ever see the buttons. + page_group: Option, /// (from, to, button label) moves: Vec<(String, String, String)>, } @@ -272,6 +280,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec { desks.push(Desk { page: q.id.clone(), group: resource.requires_group.clone().or_else(|| q.qualifies.clone()), + page_group: q.qualifies.clone(), moves: resource .transitions .iter() @@ -284,15 +293,49 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec { desks } -/// Self-service moves into `bucket` - a visitor holding their own -/// record's link, no group involved (unsubscribe). -fn self_moves(questions: &Questions, bucket: &str) -> Vec { +/// Self-service moves in `bucket` - a visitor holding their own +/// record's link, no group involved (unsubscribe, withdraw) - as +/// (from, to), from the states each may be made from. +fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String, String)> { questions .values() .flat_map(|q| &q.alternatives) .filter_map(|a| a.self_transition.as_ref()) .filter(|s| s.bucket == bucket) - .map(|s| s.to.clone()) + .flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone()))) + .collect() +} + +/// The moves a clock makes in `schema` (`after:` on a state), as +/// (from, to). Nobody presses them, and they still move records. +fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> { + let mut moves: Vec<(String, String)> = + schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect(); + moves.sort(); + moves +} + +/// States reachable from `start` along `moves`. +fn reachable(start: &str, moves: &[(String, String)]) -> HashSet { + let mut reached = HashSet::from([start.to_string()]); + let mut queue = VecDeque::from([start.to_string()]); + while let Some(state) = queue.pop_front() { + for (_, to) in moves.iter().filter(|(from, _)| *from == state) { + if reached.insert(to.clone()) { + queue.push_back(to.clone()); + } + } + } + reached +} + +/// Who else offers `from -> to` in the bucket, as "page (group)", for +/// a finding that has to say where the missing button already is. +fn offered_elsewhere(desks: &[Desk], from: &str, to: &str) -> Vec { + desks + .iter() + .filter(|d| d.moves.iter().any(|(f, t, _)| f == from && t == to)) + .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) .collect() } @@ -305,10 +348,16 @@ pub fn check( ) -> Vec { let mut findings = Vec::new(); let mut seen = HashSet::new(); + // (bucket, state) a need already reported - stranding on its way + // to done, or out of its reach - so the bucket pass does not say + // it twice. + let mut strands_reported: HashSet<(String, String)> = HashSet::new(); + let mut unreached_reported: HashSet<(String, String)> = HashSet::new(); for need in &file.needs { let mut report = |level: Level, message: String| { findings.push(Finding { need: need.id.clone(), + scope: "need", level: if need.planned || need.paused { Level::Warn } else { level }, message: match (need.paused, need.planned, level == Level::Fail) { (true, _, true) => format!("paused, not open now: {message}"), @@ -439,23 +488,44 @@ pub fn check( ); continue; } - let selfs = self_moves(questions, &need.lands_in); + // Every move that carries this need's records: the buttons on + // the desks of handled_by/also_moved_by, the visitor's own + // links, and the clocks. + let mut moves: Vec<(String, String)> = own + .iter() + .flat_map(|d| &d.moves) + .filter(|(from, to, _)| schema.allowed(from).contains(to)) + .map(|(from, to, _)| (from.clone(), to.clone())) + .collect(); + moves.extend(self_moves(questions, &need.lands_in, &schema.initial)); + moves.extend(clock_moves(schema)); let exits = |state: &str| -> Vec { - let allowed = schema.allowed(state); - let mut to: Vec = own - .iter() - .flat_map(|d| &d.moves) - .filter(|(from, to, _)| from == state && allowed.contains(to)) - .map(|(_, to, _)| to.clone()) - .collect(); - to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned()); - to + moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect() + }; + let movers = std::iter::once(need.handled_by.as_str()) + .chain(need.also_moved_by.iter().map(String::as_str)) + .map(|g| format!("{g:?}")) + .collect::>() + .join(" or "); + // The fix for a missing move: the button on handled_by's desk, + // or the group whose desk already has it added to also_moved_by. + let fix_for = |from: &str, to: &str| -> String { + let elsewhere = offered_elsewhere(&desks, from, to); + let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk"); + if elsewhere.is_empty() { + format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}") + } else { + format!( + "{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}", + elsewhere.join(", ") + ) + } }; let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect(); // Reachability walks the whole machine (a finished state may // lead on to another: subscribed, then unsubscribed). Stranding - // only counts on the way to the first finished state - what - // happens to a handled record afterwards is not this need. + // only counts on the way to the first finished state here; what + // strands a record afterwards is the bucket's finding (below). let mut reached: HashSet = HashSet::from([schema.initial.clone()]); let mut queue = VecDeque::from([(schema.initial.clone(), false)]); let mut stranded = Vec::new(); @@ -471,6 +541,26 @@ pub fn check( } } } + // The declared edges into `state` from somewhere a record does + // get to: the moves that would close the gap. + let ways_in = |state: &str| -> Vec<(String, String)> { + let mut ways: Vec<(String, String)> = reached + .iter() + .filter(|from| schema.allowed(from).iter().any(|to| to == state)) + .map(|from| (from.clone(), state.to_string())) + .collect(); + ways.sort(); + ways + }; + let missing = |state: &str| -> String { + match ways_in(state).first() { + Some((from, to)) => format!(" - fix: {}", fix_for(from, to)), + None => format!( + " - and no state a record reaches has a declared edge into it: add one in aggregates.yaml under {:?}", + need.lands_in + ), + } + }; for state in &need.done_when { if !schema.has_state(state) { report( @@ -478,11 +568,13 @@ pub fn check( format!("done_when names {state:?}, which {:?} has no such state", need.lands_in), ); } else if !reached.contains(state) { + unreached_reported.insert((need.lands_in.clone(), state.clone())); report( Level::Fail, format!( - "{:?} cannot move a record to {state:?} - no desk of theirs offers the moves", - need.handled_by + "{:?} cannot move a record to {state:?} - no desk of theirs offers the moves{}", + need.handled_by, + missing(state) ), ); } @@ -497,11 +589,36 @@ pub fn check( ), ); } else if !reached.contains(stage) { + unreached_reported.insert((need.lands_in.clone(), stage.clone())); report( Level::Fail, format!( - "stage {stage:?} exists but {:?} is offered no moves that reach it", - need.handled_by + "stage {stage:?} exists but {:?} is offered no moves that reach it{}", + need.handled_by, + missing(stage) + ), + ); + } + } + // In the business's order: each working stage reachable from + // the one before it, so a machine where "quoted" cannot follow + // "surveyed" does not pass for one that goes surveyed -> quoted. + // The finished states that close the list are alternatives + // (won, lost), not a sequence, so they are left out. + for pair in need.stages.windows(2) { + let (before, after) = (&pair[0], &pair[1]); + if done.contains(before.as_str()) || done.contains(after.as_str()) { + continue; + } + if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) { + report( + Level::Fail, + format!( + "stage {after:?} comes after {before:?} for the business, but no move leads on from {before:?} to it{}", + match schema.allowed(before).iter().find(|to| reachable(to, &moves).contains(after) || *to == after) { + Some(to) => format!(" - fix: {}", fix_for(before, to)), + None => format!(" - add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?}", need.lands_in), + } ), ); } @@ -515,22 +632,32 @@ pub fn check( } } stranded.sort(); + for state in &stranded { + strands_reported.insert((need.lands_in.clone(), state.clone())); + } for state in stranded { + let fix = match schema.allowed(&state).first() { + Some(to) => format!(" - fix: {}", fix_for(&state, to)), + None => format!( + " - and {:?} declares no way out of it: add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml", + need.lands_in + ), + }; report( Level::Fail, - format!( - "records strand in {state:?}: {:?} is offered no move out of it", - need.handled_by - ), + format!("records strand in {state:?}: {movers} is offered no move out of it{fix}"), ); } } + findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported)); + let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect(); for persona in &file.personas { if !need_ids.contains(persona.need.as_str()) { findings.push(Finding { need: persona.need.clone(), + scope: "need", level: Level::Fail, message: format!("persona {:?} points at a need that is not declared", persona.id), }); @@ -539,6 +666,188 @@ pub fn check( findings } +/// What must hold for every record in a bucket some need lands in, +/// whichever need brought it: every state is somewhere a record can +/// get to, no state a record gets to holds it with no way on (before +/// done or after), every declared move is a button someone has, every +/// desk can be seen by whoever its buttons are for, and every mail +/// goes out from a state records reach, saying only what the record +/// holds. +fn operations( + file: &NeedsFile, + questions: &Questions, + aggregates: &HashMap, + strands_reported: &HashSet<(String, String)>, + unreached_reported: &HashSet<(String, String)>, +) -> Vec { + let mut findings = Vec::new(); + let mut buckets: Vec<&str> = file.needs.iter().map(|n| n.lands_in.as_str()).collect(); + buckets.sort(); + buckets.dedup(); + for bucket in buckets { + let Some(schema) = aggregates.get(bucket) else { continue }; + let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect(); + // Only open needs make a gap fail; a bucket that only planned + // or paused needs use reports it as a warning. + let open = needs.iter().any(|n| !n.planned && !n.paused); + let mut report = |level: Level, message: String| { + findings.push(Finding { + need: bucket.to_string(), + scope: "bucket", + level: if open { level } else { Level::Warn }, + message, + }) + }; + let desks = desks_over(questions, bucket); + let mut moves: Vec<(String, String)> = desks + .iter() + .flat_map(|d| &d.moves) + .filter(|(from, to, _)| schema.allowed(from).contains(to)) + .map(|(from, to, _)| (from.clone(), to.clone())) + .collect(); + moves.extend(self_moves(questions, bucket, &schema.initial)); + moves.extend(clock_moves(schema)); + let reached = reachable(&schema.initial, &moves); + let offered = |from: &str, to: &str| moves.iter().any(|(f, t)| f == from && t == to); + // Where a missing button goes: the desk of the group answerable + // for the bucket's first need. + let home = needs + .iter() + .find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str()))) + .map(|d| d.page.clone()) + .unwrap_or_else(|| "the handling group's desk".to_string()); + let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}"); + + let mut states: Vec<&String> = schema.event_for_state.keys().collect(); + states.sort(); + for state in &states { + if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) { + continue; + } + let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect(); + ways.sort(); + report( + Level::Fail, + format!( + "state {state:?} is never reached: no desk, link or clock moves a record there - fix: {}", + match ways.first() { + Some(from) => button(from, state), + None => "declare an edge into it from a state records reach in aggregates.yaml, or remove it".to_string(), + } + ), + ); + } + for state in states.iter().filter(|s| reached.contains(**s)) { + let out = schema.allowed(state); + if out.is_empty() { + continue; // a final state: nothing should move it + } + let offered_out: Vec<&String> = out.iter().filter(|to| offered(state, to)).collect(); + if offered_out.is_empty() { + if !strands_reported.contains(&(bucket.to_string(), (*state).clone())) { + report( + Level::Fail, + format!( + "records strand in {state:?}: {bucket:?} declares moves on to {} but no desk, link or clock offers one - fix: {}", + out.join(", "), + button(state, &out[0]) + ), + ); + } + continue; + } + for to in out.iter().filter(|to| !offered(state, to)) { + report( + Level::Warn, + format!( + "the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - {}, or drop the edge from aggregates.yaml", + button(state, to) + ), + ); + } + } + for desk in &desks { + if let (Some(page_group), Some(group)) = (&desk.page_group, &desk.group) { + if page_group != group { + report( + Level::Warn, + format!( + "the desk on {} is gated twice: the page qualifies {page_group:?}, its list requires {group:?} - only members of both see the buttons; make them the same group", + desk.page + ), + ); + } + } + } + // The fields a record of this bucket holds: everything asked on + // a form that records into it. + let fields: Vec<(&str, &str)> = questions + .values() + .flat_map(|q| &q.alternatives) + .filter(|a| a.record_as.as_deref() == Some(bucket)) + .flat_map(|a| &a.features) + .flat_map(|f| &f.requirements) + .map(|r| (r.name.as_str(), r.kind.as_str())) + .collect(); + let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect(); + mail_states.sort_by(|a, b| a.0.cmp(b.0)); + for (state, mail) in mail_states { + if !reached.contains(state) { + report(Level::Warn, format!("the mail on entering {state:?} never goes out: no record reaches {state:?}")); + } + let mut unknown: Vec = Vec::new(); + for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) { + let known = match key.split_once('.') { + Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"), + None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key), + }; + if !known && !unknown.contains(&key) { + unknown.push(key); + } + } + if !unknown.is_empty() { + let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect(); + names.sort(); + names.dedup(); + report( + Level::Fail, + format!( + "the mail on entering {state:?} uses {}, which no form recording into {bucket:?} collects - it would go out blank there; the forms collect: {}", + unknown.iter().map(|k| format!("{{{k}}}")).collect::>().join(", "), + if names.is_empty() { "nothing".to_string() } else { names.join(", ") } + ), + ); + } + } + } + findings +} + +/// What the mailer fills in on every state mail, whatever the record +/// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`). +const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"]; + +/// The `{key}`s in a mail template, read the way `mail::render` reads +/// them: letters, digits, `_`, `-` and `.`, closed by `}`. +fn placeholders(template: &str) -> Vec { + let mut keys = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find('{') { + let after = &rest[start + 1..]; + let len = after + .char_indices() + .take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.') + .last() + .map(|(i, c)| i + c.len_utf8()) + .unwrap_or(0); + if len > 0 && after[len..].starts_with('}') { + keys.push(after[..len].to_string()); + } + rest = after; + } + keys +} + /// Everything a simulation of the business needs, resolved: each /// need's path with the choice on every page and the form that gets /// filled, each bucket's state machine with the buttons every desk @@ -611,6 +920,11 @@ pub fn sim_model( "steps": steps, })); if let Some(schema) = aggregates.get(&need.lands_in) { + // The simulator's shape: the states a visitor's own link + // moves to (it does not model where from). + let mut self_move_targets: Vec = + self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect(); + self_move_targets.dedup(); buckets.entry(need.lands_in.clone()).or_insert_with(|| { json!({ "initial": schema.initial, @@ -623,7 +937,7 @@ pub fn sim_model( "from": from, "to": to, "label": label, })).collect::>(), })).collect::>(), - "self_moves": self_moves(questions, &need.lands_in), + "self_moves": self_move_targets, }) }); } @@ -895,8 +1209,8 @@ mod tests { assert_eq!( fails(&findings), vec![ - "\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves", - "records strand in \"quoted\": \"owners\" is offered no move out of it", + "\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk", + "records strand in \"quoted\": \"owners\" is offered no move out of it - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk", ] ); } @@ -1037,4 +1351,102 @@ mod tests { assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]); } + + /// The fixture site with its aggregates replaced, for the checks + /// that are about the state machine rather than the pages. + fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap) { + let (questions, _) = site(desk_moves); + let aggregates = parse_aggregates_yaml(&format!( + "aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n" + )) + .unwrap(); + (questions, aggregates) + } + + fn warns(findings: &[Finding]) -> Vec<&str> { + findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect() + } + + #[test] + fn a_clock_is_a_move() { + // Nobody closes a stale quote; six weeks do. That is not a strand. + let (questions, aggregates) = site_with( + " - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n", + " initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted, after: { days: 42, to: lost } }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted]\n quoted: [won, lost]\n", + ); + let findings = check(&needs(""), &questions, &aggregates); + assert!(fails(&findings).is_empty(), "{findings:?}"); + } + + #[test] + fn a_state_nobody_reaches_and_a_strand_after_done_are_the_buckets() { + // "archived" follows "won" but no desk offers it; "parked" has + // no way in at all. + let (questions, aggregates) = site_with( + FULL_DESK, + " initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n archived: { event: archived }\n done: { event: done }\n parked: { event: parked }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n won: [archived]\n archived: [done]\n parked: [open]\n", + ); + let findings = check(&needs(""), &questions, &aggregates); + let bucket: Vec<&Finding> = findings.iter().filter(|f| f.scope == "bucket").collect(); + assert!(bucket.iter().all(|f| f.need == "projects"), "{bucket:?}"); + assert_eq!( + fails(&findings), + vec![ + "state \"archived\" is never reached: no desk, link or clock moves a record there - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk", + "state \"done\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it", + "state \"parked\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it", + "records strand in \"won\": \"projects\" declares moves on to archived but no desk, link or clock offers one - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk", + ] + ); + } + + #[test] + fn an_edge_nobody_offers_is_a_warning_when_there_is_another_way_on() { + let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n"); + let findings = check(&needs(""), &questions, &aggregates); + assert!(fails(&findings).is_empty(), "{findings:?}"); + assert_eq!( + warns(&findings), + vec!["the move \"open\" -> \"lost\" is declared but no desk, link or clock offers it - add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml"] + ); + } + + #[test] + fn working_stages_must_follow_each_other() { + // The business surveys, then quotes; this machine can only + // quote straight from open, and surveying leads nowhere. + let (questions, aggregates) = site_with( + " - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n", + " initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n", + ); + let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates); + assert_eq!( + fails(&findings), + vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""] + ); + } + + #[test] + fn a_mail_may_only_say_what_the_record_holds() { + let (questions, aggregates) = site_with( + FULL_DESK, + " initial: open\n states:\n open: { event: submitted, mail: { to: submitter, subject: 'Got it, {email}', body: 'We read {brief} and will reply at {site}. {refnr} {link}' } }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n", + ); + let findings = check(&needs(""), &questions, &aggregates); + assert_eq!( + fails(&findings), + vec!["the mail on entering \"open\" uses {refnr}, which no form recording into \"projects\" collects - it would go out blank there; the forms collect: brief, budget, email"] + ); + } + + #[test] + fn a_desk_gated_twice_is_seen_only_by_members_of_both() { + let (mut questions, aggregates) = site(FULL_DESK); + questions.get_mut("/desk").unwrap().qualifies = Some("staff".into()); + let findings = check(&needs(""), &questions, &aggregates); + assert!( + warns(&findings).contains(&"the desk on /desk is gated twice: the page qualifies \"staff\", its list requires \"owners\" - only members of both see the buttons; make them the same group"), + "{findings:?}" + ); + } } diff --git a/src/trainer/reference.md b/src/trainer/reference.md index 50f5f8d..a30a192 100644 --- a/src/trainer/reference.md +++ b/src/trainer/reference.md @@ -190,7 +190,16 @@ not done. Its messages are exact: - `no desk gated on "g" reads "b"`: add the desk. - `"g" cannot move a record to "s"` / `records strand in "s"`: the desk is missing a transition the state machine allows, or the state - machine is missing the edge. + machine is missing the edge. The message ends in `fix:` with the + exact `{ from, to, label }` and the desk page to put it on - do that. +- `state "s" is never reached`: nothing moves a record into it. Offer + the move it names, or remove the state. +- `stage "b" comes after "a" ... but no move leads on`: the machine + cannot go the way the business works; add the edge it names. +- `the mail on entering "s" uses {x}`: the mail names a field no form + collects. Use one of the fields it lists, or collect `x`. +- WARN `the move "a" -> "b" is declared but no desk ... offers it`: + offer it, or drop the edge - every edge should be a button. - `action ... does not match any declared question id`: the page does not exist. - `entry page ... names no one responsible`: add `responsible`.