Files
portal/src/lint/needs.rs
T

1453 lines
63 KiB
Rust
Raw Normal View History

//! A business's needs, stated apart from how its pages word them.
//!
//! A content repo may ship a `needs.yaml` at its root: each *need* says
//! who wants what, where they enter, which bucket the answer must land
//! in, and which group has to be able to carry it to a finished state.
//! Two things are checked against it:
//!
//! - **Structure** (`check`): deterministic. A path of submissions
//! leads from the entry page to the bucket, within an effort budget;
//! the handling group has a desk over that bucket; every finished
//! state is reachable through the moves that desk offers, and no
//! record strands on the way.
//! - **Wording** (`tasks` / `score`): each *persona* is a visitor in
//! their own words. Every page on their path becomes one typed
//! `choice` question for an external answering engine - the page's
//! question, its alternatives as the options - and the engine's picks
//! are scored against the alternatives that actually meet the need.
//! A misroute is a wording defect: the page said one thing and the
//! visitor heard another.
//!
//! The engine is deliberately outside this crate: tasks go out as
//! JSONL, answers come back as JSONL, so any classifier or LLM can sit
//! in between and be compared on the same tasks.
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use serde::{Deserialize, Serialize};
use crate::aggregates::AggregateSchema;
use crate::content::{action_target_id, Alternative, Question};
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NeedsFile {
#[serde(default)]
pub needs: Vec<Need>,
#[serde(default)]
pub personas: Vec<Persona>,
2026-09-30 15:17:20 +02:00
/// The tones of voice the site is told in, one storyline each
/// (portal docs, Storylines). Named here so the report can say
/// which tone a storyline index is; the checks read the first.
#[serde(default)]
pub tones: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Need {
pub id: String,
/// The kind of visitor - prose, for the reader of the report.
pub who: String,
pub wants: String,
/// The page they arrive on.
#[serde(default = "root")]
pub enters: String,
/// The group they hold when they arrive; unset means anonymous.
#[serde(default)]
pub enters_as: Option<String>,
/// The alternative on the entry page that serves this need, when
/// several record into the same bucket.
#[serde(default)]
pub via: Option<String>,
/// The bucket their answer must be recorded into.
pub lands_in: String,
/// The group that must be able to see the record and move it.
pub handled_by: String,
/// The states that count as handled.
pub done_when: Vec<String>,
/// Submissions the visitor may be asked for before the record
/// exists.
#[serde(default = "default_max_steps")]
pub max_steps: usize,
/// Required fields across the whole path - the onboarding effort
/// budget.
#[serde(default)]
pub max_fields: Option<usize>,
/// Declared, and knowingly not open yet: what is missing reports
/// as a warning, so the gap stays visible without failing CI.
#[serde(default)]
pub planned: bool,
/// Open before and closed for now (full up, out of season): its
/// card is on the page with no form. Reports like `planned`.
#[serde(default)]
pub paused: bool,
/// Each state of the bucket in plain words, in the site's
/// language, for the scorer: state ids are ASCII, and a Norwegian
/// `ikke_lost` reads as English to a model.
#[serde(default)]
pub stage_words: std::collections::BTreeMap<String, String>,
/// The finished states that are a good outcome for the business -
/// a subset of `done_when`. "lost" is finished; "won" is success.
/// What a simulation or a live bucket report counts toward.
#[serde(default)]
pub success: Vec<String>,
/// Every stage the business said a case goes through, in its own
/// order, first to last. Written from how the business describes
/// its work, before any state machine exists, so that a machine
/// which quietly skips a stage ("we go and look, then we quote"
/// becoming open -> quoted) fails instead of passing.
#[serde(default)]
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 {
"/".to_string()
}
fn default_max_steps() -> usize {
2
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Persona {
pub id: String,
pub need: String,
/// The visitor's situation in their own words - never the page's.
pub text: String,
}
pub fn parse(raw: &str) -> anyhow::Result<NeedsFile> {
serde_yaml::from_str(raw).map_err(|e| anyhow::anyhow!("parsing needs.yaml: {e}"))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Level {
Fail,
Warn,
}
#[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)]
pub struct Step {
pub page: String,
pub alternative: String,
}
/// One alternative on a page that still meets the need, with the
/// cheapest way on from it.
struct Viable {
alternative: String,
path: Vec<Step>,
fields: usize,
records_with_action: Option<bool>,
}
type Questions = HashMap<String, Question>;
fn visible(q: &Question, as_group: Option<&str>) -> bool {
match &q.qualifies {
None => true,
Some(group) => as_group == Some(group.as_str()),
}
}
/// What a person has to fill in: required fields, less the ones that
/// carry rather than ask - a `record` naming the case an answer is
/// about, a `hidden` preset - which cost nobody anything.
fn required_fields(alt: &Alternative) -> usize {
alt.features
.iter()
.flat_map(|f| &f.requirements)
.filter(|r| !r.optional && r.kind != "record" && r.kind != "hidden")
.count()
}
/// The alternatives on `page` from which the need is still met within
/// `remaining` submissions.
fn viable(
questions: &Questions,
need: &Need,
page: &Question,
remaining: usize,
entry: bool,
include_disabled: bool,
trail: &mut Vec<String>,
) -> Vec<Viable> {
let mut out = Vec::new();
if remaining == 0 {
return out;
}
for alt in page.alternatives.iter().filter(|a| include_disabled || !a.disabled) {
if entry && need.via.as_deref().is_some_and(|via| via != alt.name) {
continue;
}
let step = Step {
page: page.id.clone(),
alternative: alt.name.clone(),
};
let fields = required_fields(alt);
if alt.record_as.as_deref() == Some(need.lands_in.as_str()) {
out.push(Viable {
alternative: alt.name.clone(),
path: vec![step],
fields,
records_with_action: Some(alt.action.is_some()),
});
continue;
}
let Some(target) = alt
.action
.as_deref()
.and_then(|a| action_target_id(questions, a))
.and_then(|id| questions.get(&id))
else {
continue;
};
if trail.contains(&target.id) || !visible(target, need.enters_as.as_deref()) {
continue;
}
trail.push(target.id.clone());
let onward = viable(questions, need, target, remaining - 1, false, include_disabled, trail);
trail.pop();
if let Some(best) = onward.into_iter().min_by_key(|v| (v.path.len(), v.fields)) {
let mut path = vec![step];
path.extend(best.path);
out.push(Viable {
alternative: alt.name.clone(),
path,
fields: fields + best.fields,
records_with_action: best.records_with_action,
});
}
}
out
}
fn entry_viable(questions: &Questions, need: &Need, include_disabled: bool) -> Option<Vec<Viable>> {
let page = questions.get(&need.enters)?;
let mut trail = vec![page.id.clone()];
Some(viable(questions, need, page, need.max_steps, true, include_disabled, &mut trail))
}
/// The cheapest path that meets the need, if any.
pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
entry_viable(questions, need, false)?
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields))
.map(|v| v.path)
}
struct Desk {
page: String,
group: Option<String>,
/// 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<String>,
/// (from, to, button label)
moves: Vec<(String, String, String)>,
}
fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
let mut desks = Vec::new();
for q in questions.values() {
for resource in q
.alternatives
.iter()
.flat_map(|a| &a.features)
.filter_map(|f| f.resource.as_ref())
.filter(|r| r.bucket() == Some(bucket))
{
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()
.map(|t| (t.from.clone(), t.to.clone(), t.label.clone()))
.collect(),
});
}
}
desks.sort_by(|a, b| a.page.cmp(&b.page));
desks
}
/// 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)
.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<String> {
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<String> {
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()
}
/// Structural check of every need. `Fail` findings mean the business
/// need is not met by the content as written.
pub fn check(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
) -> Vec<Finding> {
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}"),
(false, true, true) => format!("planned, not met yet: {message}"),
_ => message,
},
})
};
if !seen.insert(need.id.as_str()) {
report(Level::Fail, "declared more than once".to_string());
continue;
}
// The visitor's side: can they get their answer recorded?
match questions.get(&need.enters) {
None => report(Level::Fail, format!("entry page {:?} does not exist", need.enters)),
Some(page) if !visible(page, need.enters_as.as_deref()) => report(
Level::Fail,
format!(
"entry page {:?} is gated on {:?}, which this visitor does not hold",
need.enters, page.qualifies
),
),
Some(page) => {
if page.responsible.is_none() {
report(
Level::Warn,
format!("entry page {:?} names no one responsible to turn to", need.enters),
);
}
let best = entry_viable(questions, need, false)
.unwrap_or_default()
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields));
match best {
None if entry_viable(questions, need, true).is_some_and(|v| !v.is_empty()) => {
let blocked = entry_viable(questions, need, true)
.unwrap_or_default()
.into_iter()
.min_by_key(|v| (v.path.len(), v.fields))
.map(|v| v.path)
.unwrap_or_default();
let at = blocked
.iter()
.find(|s| {
questions[&s.page]
.alternatives
.iter()
.any(|a| a.name == s.alternative && a.disabled)
})
.map(|s| format!("{:?} on {}", s.alternative, s.page))
.unwrap_or_default();
report(
Level::Fail,
format!(
"the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused"
),
)
}
None => report(
Level::Fail,
format!(
"no path from {:?}{} records into {:?} within {} submission(s)",
need.enters,
need.via
.as_ref()
.map(|v| format!(" via {v:?}"))
.unwrap_or_default(),
need.lands_in,
need.max_steps
),
),
Some(best) => {
if let Some(budget) = need.max_fields {
if best.fields > budget {
report(
Level::Fail,
format!(
"the path asks for {} required field(s), over the budget of {budget}",
best.fields
),
);
}
}
if best.records_with_action == Some(false) {
report(
Level::Warn,
"the recording alternative has no `action` - the visitor is never told what happens next".to_string(),
);
}
}
}
}
}
// The business's side: can the handling group finish it?
let Some(schema) = aggregates.get(&need.lands_in) else {
report(
Level::Fail,
format!("aggregates.yaml declares no state machine for {:?}", need.lands_in),
);
continue;
};
let desks = desks_over(questions, &need.lands_in);
let own: Vec<&Desk> = desks
.iter()
.filter(|d| {
d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g))
})
.collect();
if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) {
let others: Vec<String> = desks
.iter()
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
.collect();
report(
Level::Fail,
format!(
"no desk gated on {:?} reads {:?}{}",
need.handled_by,
need.lands_in,
if others.is_empty() {
String::new()
} else {
format!(" - it is read by {}", others.join(", "))
}
),
);
continue;
}
// 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<String> {
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::<Vec<_>>()
.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 here; what
// strands a record afterwards is the bucket's finding (below).
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
let mut stranded = Vec::new();
while let Some((state, past_done)) = queue.pop_front() {
let past_done = past_done || done.contains(state.as_str());
let next = exits(&state);
if next.is_empty() && !past_done {
stranded.push(state.clone());
}
for to in next {
if reached.insert(to.clone()) {
queue.push_back((to, past_done));
}
}
}
// 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(
Level::Fail,
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,
missing(state)
),
);
}
}
for stage in &need.stages {
if !schema.has_state(stage) {
report(
Level::Fail,
format!(
"the business describes a stage {stage:?} that {:?} has no state for",
need.lands_in
),
);
} 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,
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),
}
),
);
}
}
for state in &need.success {
if !need.done_when.contains(state) {
report(
Level::Fail,
format!("success names {state:?}, which is not one of done_when"),
);
}
}
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:?}: {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),
});
}
}
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<String, AggregateSchema>,
strands_reported: &HashSet<(String, String)>,
unreached_reported: &HashSet<(String, String)>,
) -> Vec<Finding> {
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<String> = 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::<Vec<_>>().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<String> {
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
/// offers, and the personas. Portal resolves it because only portal
/// loads content the way the runtime does (derived ids, relative
/// refs, sections); whoever simulates reads plain JSON.
pub fn sim_model(
file: &NeedsFile,
questions: &Questions,
aggregates: &HashMap<String, AggregateSchema>,
) -> serde_json::Value {
use serde_json::json;
let mut needs = Vec::new();
let mut buckets = serde_json::Map::new();
for need in &file.needs {
let Some(path) = path_for(questions, need) else {
continue;
};
let steps: Vec<serde_json::Value> = path
.iter()
.enumerate()
.map(|(i, step)| {
let page = &questions[&step.page];
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> =
viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter()
.map(|v| v.alternative)
.collect();
let fields: Vec<serde_json::Value> = page
.alternatives
.iter()
.find(|a| a.name == step.alternative)
.into_iter()
.flat_map(|a| &a.features)
.flat_map(|f| &f.requirements)
.map(|r| {
json!({
"name": r.name,
"label": r.label.clone().unwrap_or_else(|| r.name.clone()),
"type": r.kind,
"optional": r.optional,
"options": r.options,
})
})
.collect();
json!({
"page": step.page,
"question": page.name,
2026-09-23 22:36:13 +02:00
"context": page_context(page),
"options": page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).map(|a| json!({
"name": a.name,
"description": plain(&a.description),
})).collect::<Vec<_>>(),
"expect": expect,
"alternative": step.alternative,
"fields": fields,
})
})
.collect();
needs.push(json!({
"id": need.id,
"who": need.who,
"wants": need.wants,
"enters_as": need.enters_as,
"lands_in": need.lands_in,
"handled_by": need.handled_by,
"done_when": need.done_when,
"success": need.success,
"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<String> =
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,
"transitions": schema.transitions,
"words": need.stage_words,
"desks": desks_over(questions, &need.lands_in).iter().map(|d| json!({
"page": d.page,
"group": d.group,
"moves": d.moves.iter().map(|(from, to, label)| json!({
"from": from, "to": to, "label": label,
})).collect::<Vec<_>>(),
})).collect::<Vec<_>>(),
"self_moves": self_move_targets,
})
});
}
}
json!({
"needs": needs,
"buckets": buckets,
"personas": file.personas.iter().map(|p| json!({
"id": p.id, "need": p.need, "text": plain(&p.text),
})).collect::<Vec<_>>(),
})
}
/// One decision for the answering engine - the shape is laya's
/// `predict(state, questions)` input, so an adapter is a loop and
/// nothing more; any other engine reads the same fields.
#[derive(Debug, Serialize)]
pub struct Task {
pub id: String,
pub persona: String,
pub need: String,
pub page: String,
pub state: BTreeMap<&'static str, String>,
pub questions: BTreeMap<&'static str, ChoiceQuestion>,
/// Every alternative that still meets the need from here. The
/// engine never sees this.
pub expect: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct ChoiceQuestion {
#[serde(rename = "type")]
pub kind: &'static str,
pub instructions: String,
pub criteria: BTreeMap<String, String>,
}
fn plain(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
2026-09-23 22:36:13 +02:00
/// A card a visitor can act on: it leads to a page, records an
/// answer, or moves a record. A card with none of those is read, not
/// picked - the opener that says what the business does, the look
/// around - and is context on the page, never an option.
pub fn leads_somewhere(a: &Alternative) -> bool {
a.action.is_some() || a.record_as.is_some() || a.self_transition.is_some()
}
/// What the page says before its options: every card that leads
/// nowhere, its heading, description and features, as one line each.
pub fn page_context(page: &Question) -> String {
page.alternatives
.iter()
.filter(|a| !a.disabled && !leads_somewhere(a))
.map(|a| {
let mut line = a.name.clone();
let description = plain(&a.description);
if !description.is_empty() {
line.push_str(&format!(": {description}"));
}
for f in &a.features {
let text = plain(&f.description);
match (f.name.trim().is_empty(), text.is_empty()) {
(true, true) => {}
(true, false) => line.push_str(&format!(" {text}")),
(false, true) => line.push_str(&format!(" {}.", f.name.trim())),
(false, false) => line.push_str(&format!(" {}: {text}", f.name.trim())),
}
}
line
})
.collect::<Vec<_>>()
.join("\n")
}
/// Every (persona, page-on-their-path) pair with a real choice on it.
/// `skim` shows the engine only each alternative's heading - what a
/// visitor who never reads the description has to go on.
pub fn tasks(file: &NeedsFile, questions: &Questions, skim: bool) -> Vec<Task> {
let mut out = Vec::new();
for persona in &file.personas {
let Some(need) = file.needs.iter().find(|n| n.id == persona.need) else {
continue;
};
let Some(path) = path_for(questions, need) else {
continue;
};
for (i, step) in path.iter().enumerate() {
let page = &questions[&step.page];
2026-09-23 22:36:13 +02:00
let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect();
if open.len() < 2 {
continue;
}
2026-09-23 22:36:13 +02:00
let context = page_context(page);
let mut trail: Vec<String> = path[..=i].iter().map(|s| s.page.clone()).collect();
let expect: Vec<String> = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail)
.into_iter()
.map(|v| v.alternative)
.collect();
let criteria = open
.iter()
.map(|a| {
let description = plain(&a.description);
(
a.name.clone(),
if skim || description.is_empty() { a.name.clone() } else { description },
)
})
.collect();
out.push(Task {
id: format!("{}@{}", persona.id, step.page),
persona: persona.id.clone(),
need: need.id.clone(),
page: step.page.clone(),
2026-09-23 22:36:13 +02:00
state: if context.is_empty() {
BTreeMap::from([("body", plain(&persona.text))])
} else {
BTreeMap::from([("body", plain(&persona.text)), ("page", context)])
},
questions: BTreeMap::from([(
"pick",
ChoiceQuestion {
kind: "choice",
instructions: format!(
"A visitor reads the page question {:?}. Which alternative do they choose?",
page.name
),
criteria,
},
)]),
expect,
});
}
}
out
}
#[derive(Debug, Deserialize)]
pub struct Answer {
pub id: String,
pub choice: String,
#[serde(default)]
pub confidence: Option<f64>,
}
#[derive(Debug, Default)]
pub struct Score {
pub total: usize,
pub correct: usize,
/// (task id, picked, expected, confidence)
pub misroutes: Vec<(String, String, Vec<String>, Option<f64>)>,
/// Right, but the engine was under `hesitant_below` - wording that
/// works and is still close to a coin flip.
pub hesitant: Vec<(String, f64)>,
pub unanswered: Vec<String>,
}
impl Score {
pub fn accuracy(&self) -> f64 {
if self.total == 0 {
1.0
} else {
self.correct as f64 / self.total as f64
}
}
}
pub fn score(tasks: &[Task], answers: &[Answer], hesitant_below: f64) -> Score {
let by_id: HashMap<&str, &Answer> = answers.iter().map(|a| (a.id.as_str(), a)).collect();
let mut score = Score {
total: tasks.len(),
..Score::default()
};
for task in tasks {
match by_id.get(task.id.as_str()) {
None => score.unanswered.push(task.id.clone()),
Some(answer) if task.expect.contains(&answer.choice) => {
score.correct += 1;
if let Some(c) = answer.confidence.filter(|c| *c < hesitant_below) {
score.hesitant.push((task.id.clone(), c));
}
}
Some(answer) => score.misroutes.push((
task.id.clone(),
answer.choice.clone(),
task.expect.clone(),
answer.confidence,
)),
}
}
score
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aggregates::parse_aggregates_yaml;
use crate::content::build_questions;
fn site(desk_moves: &str) -> (Questions, HashMap<String, AggregateSchema>) {
let files = vec![
(
"index.yaml".to_string(),
"name: What are you here for?\nresponsible: { name: A, contact: a@b.c }\nalternatives:\n - name: Hire us\n description: Bring a problem.\n action: /thanks\n record_as: projects\n features:\n - name: Brief\n requirements:\n - name: email\n type: email\n - name: brief\n type: textarea\n - name: budget\n optional: true\n - name: Follow along\n description: Notes by mail.\n action: /thanks\n record_as: subscribers\n".to_string(),
),
("thanks.yaml".to_string(), "name: Thanks\nfollowup: true\n".to_string()),
(
"desk/index.yaml".to_string(),
format!("name: Desk\nqualifies: owners\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: {{ kind: kv, bucket: projects }}\n requires_group: owners\n transitions:\n{desk_moves}"),
),
];
let aggregates = parse_aggregates_yaml(
"aggregates:\n - bucket: projects\n initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: { event: subscribed }\n transitions: {}\n",
)
.unwrap();
(build_questions(&files).unwrap(), aggregates)
}
const FULL_DESK: &str = " - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n";
fn needs(extra: &str) -> NeedsFile {
parse(&format!(
"needs:\n - id: hire\n who: a client\n wants: work done\n lands_in: projects\n handled_by: owners\n done_when: [won, lost]\n{extra}personas:\n - id: founder\n need: hire\n text: I need someone to build our booking tool.\n"
))
.unwrap()
}
fn fails(findings: &[Finding]) -> Vec<&str> {
findings
.iter()
.filter(|f| f.level == Level::Fail)
.map(|f| f.message.as_str())
.collect()
}
#[test]
fn a_met_need_is_silent() {
let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn effort_budget_counts_required_fields_only() {
let (questions, aggregates) = site(FULL_DESK);
let findings = check(&needs(" max_fields: 1\n"), &questions, &aggregates);
assert_eq!(fails(&findings), vec!["the path asks for 2 required field(s), over the budget of 1"]);
// A field that carries rather than asks is not effort: the
// record an answer is about arrives on the link.
let (mut questions, aggregates) = site(FULL_DESK);
let front = questions.get_mut("/").unwrap();
let brief = &mut front.alternatives[0].features[0].requirements;
let mut carrier = brief[0].clone();
carrier.name = "case".into();
carrier.kind = "record".into();
carrier.of = Some("projects".into());
carrier.optional = false;
brief.push(carrier);
let findings = check(&needs(" max_fields: 2\n"), &questions, &aggregates);
assert!(fails(&findings).is_empty(), "{findings:?}");
}
#[test]
fn a_desk_missing_a_move_strands_records_and_cannot_finish() {
// The desk can quote, but never close a quoted job.
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec![
"\"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",
]
);
}
#[test]
fn a_finished_state_may_lead_on_to_another() {
// quoted counts as handled here, yet won must still be
// reachable past it; lost-after-quoted is not this need's
// business, so nothing strands.
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].done_when = vec!["quoted".to_string(), "won".to_string(), "lost".to_string()];
let findings = check(&file, &questions, &aggregates);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn a_disabled_alternative_is_named_as_the_reason() {
let (mut questions, aggregates) = site(FULL_DESK);
questions.get_mut("/").unwrap().alternatives[0].disabled = true;
let findings = check(&needs(""), &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["the path exists but is closed: \"Hire us\" on / is `disabled`, so the answer is advertised and refused"]
);
}
#[test]
fn success_must_be_a_finished_state_and_the_sim_model_carries_the_whole_flow() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs(" success: [won]\n");
assert!(check(&file, &questions, &aggregates).is_empty());
let model = sim_model(&file, &questions, &aggregates);
let need = &model["needs"][0];
assert_eq!(need["success"][0], "won");
let step = &need["steps"][0];
assert_eq!(step["question"], "What are you here for?");
assert_eq!(step["expect"][0], "Hire us");
assert_eq!(step["options"].as_array().unwrap().len(), 2);
// The form the visitor fills, required and optional alike.
let fields = step["fields"].as_array().unwrap();
assert_eq!(fields.len(), 3);
assert_eq!(fields[2]["optional"], true);
// The desk's buttons, labelled, per state.
let moves = model["buckets"]["projects"]["desks"][0]["moves"].as_array().unwrap();
assert!(moves.iter().any(|m| m["from"] == "quoted" && m["to"] == "won" && m["label"] == "Win"));
assert_eq!(model["buckets"]["projects"]["initial"], "open");
assert_eq!(model["personas"][0]["id"], "founder");
// A stage the business named must exist as a state.
file.needs[0].stages = vec!["open".into(), "surveyed".into(), "quoted".into()];
assert_eq!(
fails(&check(&file, &questions, &aggregates)),
vec!["the business describes a stage \"surveyed\" that \"projects\" has no state for"]
);
file.needs[0].stages = vec!["open".into(), "quoted".into(), "won".into()];
assert!(check(&file, &questions, &aggregates).is_empty());
file.needs[0].success = vec!["quoted".to_string()];
assert_eq!(
fails(&check(&file, &questions, &aggregates)),
vec!["success names \"quoted\", which is not one of done_when"]
);
}
#[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]
fn the_wrong_group_holding_the_desk_is_a_failure() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].handled_by = "sales".to_string();
let findings = check(&file, &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["no desk gated on \"sales\" reads \"projects\" - it is read by /desk (owners)"]
);
}
#[test]
fn via_pins_the_entry_alternative() {
let (questions, aggregates) = site(FULL_DESK);
let mut file = needs("");
file.needs[0].via = Some("Follow along".to_string());
let findings = check(&file, &questions, &aggregates);
assert_eq!(
fails(&findings),
vec!["no path from \"/\" via \"Follow along\" records into \"projects\" within 2 submission(s)"]
);
}
#[test]
fn tasks_carry_the_page_as_a_choice_and_score_separates_misroutes() {
let (questions, _) = site(FULL_DESK);
let file = needs("");
let tasks = tasks(&file, &questions, false);
let skimmed = super::tasks(&file, &questions, true);
assert_eq!(skimmed[0].questions["pick"].criteria["Follow along"], "Follow along");
assert_eq!(tasks.len(), 1);
let task = &tasks[0];
assert_eq!(task.id, "founder@/");
assert_eq!(task.expect, vec!["Hire us"]);
let q = &task.questions["pick"];
assert_eq!(q.kind, "choice");
assert_eq!(q.criteria["Follow along"], "Notes by mail.");
assert!(q.instructions.contains("What are you here for?"));
let right = vec![Answer { id: "founder@/".into(), choice: "Hire us".into(), confidence: Some(0.55) }];
let s = score(&tasks, &right, 0.6);
assert_eq!((s.correct, s.misroutes.len(), s.hesitant.len()), (1, 0, 1));
let wrong = vec![Answer { id: "founder@/".into(), choice: "Follow along".into(), confidence: Some(0.9) }];
let s = score(&tasks, &wrong, 0.6);
assert_eq!((s.correct, s.misroutes.len()), (0, 1));
assert_eq!(s.accuracy(), 0.0);
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<String, AggregateSchema>) {
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:?}"
);
}
}