//! 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 portal::aggregates::AggregateSchema; use portal::content::{action_target_id, Alternative, Question}; #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct NeedsFile { #[serde(default)] pub needs: Vec, #[serde(default)] pub personas: Vec, } #[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, /// The alternative on the entry page that serves this need, when /// several record into the same bucket. #[serde(default)] pub via: Option, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, /// 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, } 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 { 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 { pub need: String, pub level: Level, pub message: String, } #[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, fields: usize, records_with_action: Option, } type Questions = HashMap; 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, ) -> Vec { 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> { 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> { 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, /// (from, to, button label) moves: Vec<(String, String, String)>, } fn desks_over(questions: &Questions, bucket: &str) -> Vec { 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()), 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 into `bucket` - a visitor holding their own /// record's link, no group involved (unsubscribe). fn self_moves(questions: &Questions, bucket: &str) -> Vec { 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()) .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, ) -> Vec { let mut findings = Vec::new(); let mut seen = HashSet::new(); for need in &file.needs { let mut report = |level: Level, message: String| { findings.push(Finding { need: need.id.clone(), level: if need.planned { Level::Warn } else { level }, message: if need.planned && level == Level::Fail { format!("planned, not met yet: {message}") } else { 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 = 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; } let selfs = self_moves(questions, &need.lands_in); 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 }; 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. let mut reached: HashSet = 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)); } } } 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) { report( Level::Fail, format!( "{:?} cannot move a record to {state:?} - no desk of theirs offers the moves", need.handled_by ), ); } } 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) { report( Level::Fail, format!( "stage {stage:?} exists but {:?} is offered no moves that reach it", need.handled_by ), ); } } 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 { report( Level::Fail, format!( "records strand in {state:?}: {:?} is offered no move out of it", need.handled_by ), ); } } 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(), level: Level::Fail, message: format!("persona {:?} points at a need that is not declared", persona.id), }); } } findings } /// 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, ) -> 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 = path .iter() .enumerate() .map(|(i, step)| { let page = &questions[&step.page]; let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); let expect: Vec = viable(questions, need, page, need.max_steps - i, i == 0, false, &mut trail) .into_iter() .map(|v| v.alternative) .collect(); let fields: Vec = 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, "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::>(), "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) { 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::>(), })).collect::>(), "self_moves": self_moves(questions, &need.lands_in), }) }); } } json!({ "needs": needs, "buckets": buckets, "personas": file.personas.iter().map(|p| json!({ "id": p.id, "need": p.need, "text": plain(&p.text), })).collect::>(), }) } /// 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, } #[derive(Debug, Serialize)] pub struct ChoiceQuestion { #[serde(rename = "type")] pub kind: &'static str, pub instructions: String, pub criteria: BTreeMap, } fn plain(text: &str) -> String { text.split_whitespace().collect::>().join(" ") } /// 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::>() .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 { 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]; let open: Vec<&Alternative> = page.alternatives.iter().filter(|a| !a.disabled && leads_somewhere(a)).collect(); if open.len() < 2 { continue; } let context = page_context(page); let mut trail: Vec = path[..=i].iter().map(|s| s.page.clone()).collect(); let expect: Vec = 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(), 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, } #[derive(Debug, Default)] pub struct Score { pub total: usize, pub correct: usize, /// (task id, picked, expected, confidence) pub misroutes: Vec<(String, String, Vec, Option)>, /// 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, } 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 portal::aggregates::parse_aggregates_yaml; use portal::content::build_questions; fn site(desk_moves: &str) -> (Questions, HashMap) { 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", "records strand in \"quoted\": \"owners\" is offered no move out of it", ] ); } #[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@/"]); } }