diff --git a/src/bin/question_lint.rs b/src/bin/question_lint.rs index 20ad00a..73ce1f4 100644 --- a/src/bin/question_lint.rs +++ b/src/bin/question_lint.rs @@ -22,6 +22,7 @@ async fn main() -> anyhow::Result<()> { let mut answers_in: Option = None; let mut min_accuracy: f64 = 0.0; let mut skim = false; + let mut sim_out: Option = None; while let Some(arg) = args.next() { match arg.as_str() { @@ -31,6 +32,7 @@ async fn main() -> anyhow::Result<()> { "--path" => path = args.next(), "--needs-tasks" => tasks_out = args.next(), "--skim" => skim = true, + "--needs-sim" => sim_out = args.next(), "--needs-score" => answers_in = args.next(), "--min-accuracy" => { min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy) @@ -59,7 +61,7 @@ async fn main() -> anyhow::Result<()> { (questions, aggregates_map, site, needs_raw) } else { eprintln!( - "usage: question-lint (--repo [--branch main] [--subdir questions] | --path ) [--needs-tasks [--skim]] [--needs-score [--min-accuracy 0.8]]" + "usage: question-lint (--repo [--branch main] [--subdir questions] | --path ) [--needs-tasks [--skim]] [--needs-sim ] [--needs-score [--min-accuracy 0.8]]" ); std::process::exit(2); }; @@ -94,6 +96,7 @@ async fn main() -> anyhow::Result<()> { &aggregates_map, tasks_out.as_deref(), skim, + sim_out.as_deref(), answers_in.as_deref(), min_accuracy, ) @@ -205,6 +208,7 @@ fn check_needs( aggregates_map: &std::collections::HashMap, tasks_out: Option<&str>, skim: bool, + sim_out: Option<&str>, answers_in: Option<&str>, min_accuracy: f64, ) -> anyhow::Result<()> { @@ -255,6 +259,12 @@ fn check_needs( // The wording pass still runs for the needs that do have a path - // a closed flow elsewhere is no reason to stop grading the rest. + if let Some(out) = sim_out { + let model = needs::sim_model(&file, questions, aggregates_map); + std::fs::write(out, serde_json::to_string_pretty(&model)?) + .map_err(|e| anyhow::anyhow!("writing {out}: {e}"))?; + println!("wrote the simulation model to {out}"); + } let tasks = needs::tasks(&file, questions, skim); if let Some(out) = tasks_out { let mut lines = String::new(); diff --git a/src/needs.rs b/src/needs.rs index ad169ff..27ce66d 100644 --- a/src/needs.rs +++ b/src/needs.rs @@ -74,6 +74,11 @@ pub struct Need { /// as a warning, so the gap stays visible without failing CI. #[serde(default)] pub planned: bool, + /// 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, } fn root() -> String { @@ -220,7 +225,8 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option> { struct Desk { page: String, group: Option, - moves: Vec<(String, String)>, + /// (from, to, button label) + moves: Vec<(String, String, String)>, } fn desks_over(questions: &Questions, bucket: &str) -> Vec { @@ -239,7 +245,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec { moves: resource .transitions .iter() - .map(|t| (t.from.clone(), t.to.clone())) + .map(|t| (t.from.clone(), t.to.clone(), t.label.clone())) .collect(), }); } @@ -407,8 +413,8 @@ pub fn check( let mut to: Vec = own .iter() .flat_map(|d| &d.moves) - .filter(|(from, to)| from == state && allowed.contains(to)) - .map(|(_, to)| to.clone()) + .filter(|(from, to, _)| from == state && allowed.contains(to)) + .map(|(_, to, _)| to.clone()) .collect(); to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned()); to @@ -449,6 +455,14 @@ pub fn check( ); } } + 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( @@ -474,6 +488,102 @@ pub fn check( 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, + "options": page.alternatives.iter().filter(|a| !a.disabled).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, + "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. @@ -708,6 +818,36 @@ mod tests { ); } + #[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"); + + 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 the_wrong_group_holding_the_desk_is_a_failure() { let (questions, aggregates) = site(FULL_DESK);