Files
portal/src/build.rs
T

646 lines
30 KiB
Rust
Raw Normal View History

//! Structure from the plan, words from the model.
//!
//! A `plan.yaml` is a needs spec that also says what each form collects.
//! From it, with no model involved, come `needs.yaml`,
//! `aggregates.yaml`, `site.yaml`, the front door, a followup page per
//! need, a directory per group (gated by `_section.yaml`) holding its
//! desk and the pages only that group may open, and every desk button
//! the state machine allows. What the business said determines all of
//! that; a model has nothing to decide there and much to get wrong
//! (measured: a dropped stage from a strong model, no loadable site at
//! all from a small one).
//!
//! The model writes the words: one small JSON object per page - the
//! question, headings, descriptions, field and button labels - in the
//! plan's language. Words are merged over built-in fallbacks key by
//! key, so a reply that is missing, malformed or partial costs wording,
//! never a working site.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use anyhow::{anyhow, bail, Context};
use serde::Deserialize;
use serde_json::{json, Map, Value};
use crate::simulate::first_object;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
pub business: String,
#[serde(default = "english")]
pub language: String,
pub contact: Contact,
/// What the business does, in its own words - context for the
/// model's wording, never structure.
#[serde(default)]
pub about: String,
pub needs: Vec<PlanNeed>,
#[serde(default)]
pub personas: Vec<Value>,
}
fn english() -> String {
"en".to_string()
}
#[derive(Debug, Deserialize)]
pub struct Contact {
pub name: String,
pub email: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlanNeed {
pub id: String,
pub who: String,
pub wants: String,
#[serde(default)]
pub enters_as: Option<String>,
pub lands_in: String,
pub handled_by: String,
/// Every stage, first to last, finished states included.
pub stages: Vec<String>,
pub done_when: Vec<String>,
#[serde(default)]
pub success: Vec<String>,
/// Stages a case may pass over ("awaiting papers" when the papers
/// were complete).
#[serde(default)]
pub skippable: Vec<String>,
pub collects: Vec<Field>,
#[serde(default)]
pub max_fields: Option<usize>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct Field {
pub name: String,
#[serde(default = "text", rename = "type")]
pub kind: String,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub options: Vec<String>,
}
fn text() -> String {
"text".to_string()
}
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select"];
fn is_id(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
}
impl Plan {
pub fn validate(&self) -> anyhow::Result<()> {
let mut seen = BTreeSet::new();
for need in &self.needs {
let at = |what: &str| format!("need {:?}: {what}", need.id);
if !is_id(&need.id) || !seen.insert(&need.id) {
bail!(at("id must be unique ASCII lowercase with - or _"));
}
for id in [&need.lands_in, &need.handled_by].into_iter().chain(&need.enters_as).chain(&need.stages) {
if !is_id(id) {
bail!(at(&format!("{id:?} is an identifier and must be ASCII lowercase with - or _")));
}
}
if need.stages.len() < 2 {
bail!(at("stages needs a first stage and at least one finished one"));
}
for state in need.done_when.iter().chain(&need.skippable) {
if !need.stages.contains(state) {
bail!(at(&format!("{state:?} is not one of its stages")));
}
}
for state in &need.success {
if !need.done_when.contains(state) {
bail!(at(&format!("success {state:?} is not one of done_when")));
}
}
if need.done_when.is_empty() || need.done_when.contains(&need.stages[0]) {
bail!(at("done_when must name finished states, and the first stage cannot be one"));
}
for field in &need.collects {
if !is_id(&field.name) || !FIELD_TYPES.contains(&field.kind.as_str()) {
bail!(at(&format!("field {:?}: name must be an identifier and type one of {FIELD_TYPES:?}", field.name)));
}
if field.kind == "select" && field.options.is_empty() {
bail!(at(&format!("field {:?} is a select and lists no options", field.name)));
}
}
}
// Needs sharing a bucket share its machine.
for (bucket, needs) in self.by_bucket() {
let first = needs[0];
for other in &needs[1..] {
if other.stages != first.stages || other.done_when != first.done_when || other.skippable != first.skippable {
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when and skippable", first.id, other.id);
}
}
}
Ok(())
}
fn by_bucket(&self) -> BTreeMap<&str, Vec<&PlanNeed>> {
let mut map: BTreeMap<&str, Vec<&PlanNeed>> = BTreeMap::new();
for need in &self.needs {
map.entry(&need.lands_in).or_default().push(need);
}
map
}
}
/// The moves out of every unfinished stage. The unfinished stages form
/// the line a case walks; from each, the next stage, plus whatever lies
/// beyond a run of skippable ones, plus every finished state that is
/// not a success (a case can be turned down at any point). Success is
/// only reachable from the end of the line.
pub fn machine(need: &PlanNeed) -> BTreeMap<String, Vec<String>> {
let line: Vec<&String> = need.stages.iter().filter(|s| !need.done_when.contains(s)).collect();
let ends_well: Vec<&String> = if need.success.is_empty() { need.done_when.iter().collect() } else { need.success.iter().collect() };
let ends_badly: Vec<&String> = need.done_when.iter().filter(|s| !ends_well.contains(s)).collect();
let mut moves = BTreeMap::new();
for (i, stage) in line.iter().enumerate() {
let mut to: Vec<String> = Vec::new();
let mut j = i + 1;
loop {
match line.get(j) {
Some(next) => {
to.push((*next).clone());
if !need.skippable.contains(next) {
break;
}
j += 1;
}
None => {
to.extend(ends_well.iter().map(|s| (*s).clone()));
break;
}
}
}
to.extend(ends_badly.iter().map(|s| (*s).clone()));
moves.insert((*stage).clone(), to);
}
moves
}
fn humanize(id: &str) -> String {
let mut words = id.replace(['_', '-'], " ");
if let Some(first) = words.get_mut(0..1) {
first.make_ascii_uppercase();
}
words
}
fn public_followup(need: &PlanNeed) -> String {
format!("/received/{}", need.id)
}
fn internal_page(need: &PlanNeed, group: &str) -> String {
format!("/{group}/{}", need.id)
}
/// The words every page needs, as built-in fallbacks: plain, English,
/// made from the plan's own text. Keyed by file path, then the same
/// JSON shape the model is asked for.
pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
let form = |need: &PlanNeed| {
json!({
"heading": need.wants,
"description": format!("For {}.", need.who),
"button": "Send",
"form_title": "Details",
"labels": need.collects.iter().map(|f| (f.name.clone(), Value::String(humanize(&f.name)))).collect::<Map<_, _>>(),
})
};
let mut words = BTreeMap::new();
let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect();
words.insert(
"questions/index.yaml".to_string(),
json!({
"question": "What brings you here?",
"description": plan.about,
"needs": public.iter().map(|n| (n.id.clone(), form(n))).collect::<Map<_, _>>(),
}),
);
for need in &plan.needs {
let (path, back) = match &need.enters_as {
None => (format!("questions/received/{}.yaml", need.id), "/".to_string()),
Some(group) => (format!("questions/{group}/{}-sent.yaml", need.id), format!("/{group}")),
};
let _ = back;
words.insert(
path,
json!({
"question": "What happens now?",
"description": format!("It has reached our {}.", humanize(&need.handled_by).to_lowercase()),
"next_heading": "We read it and come back to you",
"next_description": format!("Questions in the meantime go to {} at {}.", plan.contact.name, plan.contact.email),
"back": "Back",
}),
);
if let Some(group) = &need.enters_as {
let mut page = form(need);
page["question"] = json!(format!("{}?", humanize(&need.id)));
page["page_description"] = json!("");
words.insert(format!("questions/{group}/{}.yaml", need.id), page);
}
}
for group in groups(plan) {
let buckets: Map<String, Value> = plan
.by_bucket()
.into_iter()
.filter(|(_, needs)| needs.iter().any(|n| n.handled_by == group))
.map(|(bucket, needs)| {
let moves: Map<String, Value> = machine(needs[0])
.into_iter()
.flat_map(|(from, tos)| tos.into_iter().map(move |to| (format!("{from}->{to}"), Value::String(humanize(&to)))))
.collect();
(bucket.to_string(), json!({ "heading": humanize(bucket), "description": "", "moves": moves }))
})
.collect();
let links: Map<String, Value> = plan
.needs
.iter()
.filter(|n| n.enters_as.as_deref() == Some(group.as_str()))
.map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" })))
.collect();
words.insert(
format!("questions/{group}/index.yaml"),
json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }),
);
}
words
}
fn groups(plan: &Plan) -> BTreeSet<String> {
plan.needs.iter().flat_map(|n| [Some(n.handled_by.clone()), n.enters_as.clone()]).flatten().collect()
}
/// Takes the model's strings where it gave them, key by key, and keeps
/// the fallback everywhere else. The shape always comes from the
/// fallback, so a reply can never add, drop or restructure anything.
pub fn merge_words(fallback: &Value, given: &Value) -> Value {
match (fallback, given) {
(Value::Object(f), Value::Object(g)) => {
Value::Object(f.iter().map(|(k, v)| (k.clone(), g.get(k).map_or_else(|| v.clone(), |gv| merge_words(v, gv)))).collect())
}
// An unfilled slot echoed back is no answer.
(Value::String(_), Value::String(s)) if !s.trim().is_empty() && !s.contains(SLOT) => {
Value::String(s.trim().to_string())
}
_ => fallback.clone(),
}
}
const SLOT: &str = "<WRITE";
/// The page's wording with every string replaced by a slot that says
/// what belongs there. Shown to the model in place of the fallbacks:
/// fallbacks read like finished copy, and a small model hands finished
/// copy straight back (measured: nemotron returned the front door
/// unchanged). A slot cannot be mistaken for an answer.
fn slots(value: &Value, key: &str) -> Value {
match value {
Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), slots(v, k))).collect()),
Value::String(_) => Value::String(format!(
"{SLOT}: {}>",
match key {
"question" => "the one question this page asks its reader".to_string(),
"heading" | "next_heading" => "a heading, at most eight words, saying what the reader gets".to_string(),
"description" | "page_description" | "next_description" => "one or two short sentences".to_string(),
"button" | "back" | "confirm" => "a button, one to three words".to_string(),
"form_title" => "a title for the form, two to four words".to_string(),
other if other.contains("->") => format!("a button, two to four words, for a case going {other}"),
other => format!("a form label for the field `{other}`"),
}
)),
other => other.clone(),
}
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or_default()
}
fn form_alternative(need: &PlanNeed, w: &Value, action: &str) -> Value {
json!({
"name": s(&w["heading"]),
"description": s(&w["description"]),
"action": action,
"consequence": [s(&w["button"])],
"record_as": need.lands_in,
"features": [{
"name": s(&w["form_title"]),
"requirements": need.collects.iter().map(|f| {
let mut r = json!({ "name": f.name, "label": s(&w["labels"][&f.name]), "type": f.kind });
if f.optional { r["optional"] = json!(true); }
if !f.options.is_empty() { r["options"] = json!(f.options); }
r
}).collect::<Vec<_>>(),
}],
})
}
/// Every file of the content repo, from the plan and a set of words.
pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BTreeMap<String, String>> {
let mut files: BTreeMap<String, Value> = BTreeMap::new();
let responsible = json!({ "name": plan.contact.name, "contact": plan.contact.email });
files.insert("site.yaml".into(), json!({ "title": plan.business, "lang": plan.language }));
files.insert(
"aggregates.yaml".into(),
json!({ "aggregates": plan.by_bucket().into_iter().map(|(bucket, needs)| {
let need = needs[0];
let initial = &need.stages[0];
let first_event = if need.stages.iter().any(|st| st == "received") { "submitted" } else { "received" };
json!({
"bucket": bucket,
"initial": initial,
"states": need.stages.iter().map(|st| (st.clone(), json!({ "event": if st == initial { first_event } else { st.as_str() } }))).collect::<Map<_, _>>(),
"transitions": machine(need),
})
}).collect::<Vec<_>>() }),
);
// The front door: one alternative per need the public may bring.
let front = &words["questions/index.yaml"];
let public: Vec<&PlanNeed> = plan.needs.iter().filter(|n| n.enters_as.is_none()).collect();
files.insert(
"questions/index.yaml".into(),
json!({
"name": s(&front["question"]),
"description": s(&front["description"]),
"responsible": responsible,
"alternatives": public.iter().map(|n| form_alternative(n, &front["needs"][&n.id], &public_followup(n))).collect::<Vec<_>>(),
}),
);
for need in &plan.needs {
let (path, back) = match &need.enters_as {
None => (format!("questions/received/{}.yaml", need.id), "/".to_string()),
Some(group) => (format!("questions/{group}/{}-sent.yaml", need.id), format!("/{group}")),
};
let w = &words[&path];
// Nested and not an index: portal infers a followup page.
files.insert(
path,
json!({
"name": s(&w["question"]),
"description": s(&w["description"]),
"alternatives": [
{ "name": s(&w["next_heading"]), "description": s(&w["next_description"]) },
{ "name": s(&w["back"]), "description": "", "action": back, "consequence": [s(&w["back"])] },
],
}),
);
if let Some(group) = &need.enters_as {
let path = format!("questions/{group}/{}.yaml", need.id);
let w = &words[&path];
files.insert(
path,
json!({
// A way in, not an outcome, though it sits in a directory.
"followup": false,
"name": s(&w["question"]),
"description": s(&w["page_description"]),
"responsible": responsible,
"alternatives": [form_alternative(need, w, &format!("/{group}/{}-sent", need.id))],
}),
);
}
}
for group in groups(plan) {
let w = &words[&format!("questions/{group}/index.yaml")];
let mut alternatives = Vec::new();
for (bucket, needs) in plan.by_bucket() {
if !needs.iter().any(|n| n.handled_by == group) {
continue;
}
let bw = &w["buckets"][bucket];
alternatives.push(json!({
"name": s(&bw["heading"]),
"description": s(&bw["description"]),
"action": format!("/{group}"),
"consequence": [s(&w["confirm"])],
"features": [{ "name": "", "description": "", "resource": {
"source": { "kind": "kv", "bucket": bucket },
"requires_group": group,
"transitions": machine(needs[0]).into_iter().flat_map(|(from, tos)| {
tos.into_iter().map(|to| json!({ "from": from, "to": to, "label": s(&bw["moves"][&format!("{from}->{to}")]) })).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
}}],
}));
}
for need in plan.needs.iter().filter(|n| n.enters_as.as_deref() == Some(group.as_str())) {
let lw = &w["links"][&need.id];
alternatives.push(json!({
"name": s(&lw["heading"]),
"description": s(&lw["description"]),
"action": internal_page(need, &group),
"consequence": [s(&lw["button"])],
}));
}
files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group }));
files.insert(
format!("questions/{group}/index.yaml"),
json!({ "name": s(&w["question"]), "description": s(&w["description"]), "responsible": responsible, "alternatives": alternatives }),
);
}
// The spec the verifier holds the site to. `via` is the heading the
// words gave that need, filled in mechanically.
files.insert(
"needs.yaml".into(),
json!({
"needs": plan.needs.iter().map(|n| {
let (enters, heading) = match &n.enters_as {
None => ("/".to_string(), s(&front["needs"][&n.id]["heading"]).to_string()),
Some(group) => (internal_page(n, group), s(&words[&format!("questions/{group}/{}.yaml", n.id)]["heading"]).to_string()),
};
let required = n.collects.iter().filter(|f| !f.optional).count();
let mut need = json!({
"id": n.id, "who": n.who, "wants": n.wants, "enters": enters, "via": heading,
"lands_in": n.lands_in, "handled_by": n.handled_by,
"stages": n.stages, "done_when": n.done_when, "success": n.success,
"max_steps": 1, "max_fields": n.max_fields.unwrap_or(required),
});
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
need
}).collect::<Vec<_>>(),
"personas": plan.personas,
}),
);
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
let purpose = if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question; under `needs`, each entry is one alternative the visitor can pick, with the form behind it."
} else if path.ends_with("/index.yaml") {
"a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open."
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
"a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next."
} else {
"a page only one role inside the business can open, holding one form."
};
let context: Vec<String> = plan
.needs
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> ")))
.collect();
format!(
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.",
plan.business,
plan.about.trim(),
plan.contact.name,
plan.contact.email,
plan.language,
context.join("\n"),
serde_json::to_string_pretty(&slots(fallback, "")).unwrap_or_default(),
)
}
pub fn run(argv: &[String]) -> anyhow::Result<()> {
let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned();
let usage = "usage: portal-trainer build --plan <plan.yaml> --out <dir> [--llm 'cmd'] [--lint question_lint]";
let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?;
let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?);
let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?)
.with_context(|| format!("parsing {plan_path}"))?;
plan.validate()?;
let mut words = fallback_words(&plan);
if let Some(cmd) = value("--llm") {
std::fs::create_dir_all(out.join("llm"))?;
for (n, (path, fallback)) in words.clone().iter().enumerate() {
let prompt = words_prompt(&plan, path, fallback);
let reply = crate::llm_with(&cmd, &prompt, &[("FORMAT", "json"), ("TEMPERATURE", "0.4")]).unwrap_or_default();
std::fs::write(out.join(format!("llm/{:02}-{}.md", n + 1, path.replace('/', "_"))), format!("{prompt}\n\n=== REPLY ===\n{reply}"))?;
match first_object(&reply) {
Some(given) => {
words.insert(path.clone(), merge_words(fallback, &given));
eprintln!("words: {path}");
}
None => eprintln!("words: {path} - no usable reply, keeping the fallback"),
}
}
}
write_repo(&out, render(&plan, &words)?)?;
println!("content repo written to {}", out.display());
if let Some(lint) = value("--lint") {
let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?;
print!("{}{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
if !output.status.success() {
bail!("the verifier rejected a deterministically built site - that is a bug in the builder, not in the words");
}
}
Ok(())
}
fn write_repo(out: &Path, files: BTreeMap<String, String>) -> anyhow::Result<()> {
// The questions tree is rebuilt whole, so a page from an earlier
// plan never lingers.
let _ = std::fs::remove_dir_all(out.join("questions"));
for (path, body) in files {
let target = out.join(&path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(target, body)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn need(stages: &[&str], done: &[&str], success: &[&str], skippable: &[&str]) -> PlanNeed {
let v = |xs: &[&str]| xs.iter().map(|x| x.to_string()).collect::<Vec<_>>();
PlanNeed {
id: "n".into(), who: "w".into(), wants: "x".into(), enters_as: None,
lands_in: "b".into(), handled_by: "g".into(),
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
collects: vec![], max_fields: None,
}
}
#[test]
fn the_machine_walks_the_line_and_can_be_turned_down_anywhere() {
let m = machine(&need(&["open", "surveyed", "quoted", "won", "lost"], &["won", "lost"], &["won"], &[]));
assert_eq!(m["open"], ["surveyed", "lost"]);
assert_eq!(m["surveyed"], ["quoted", "lost"]);
assert_eq!(m["quoted"], ["won", "lost"]);
assert!(!m.contains_key("won"));
}
#[test]
fn a_skippable_stage_can_be_passed_over() {
let m = machine(&need(&["open", "awaiting_papers", "approved", "declined"], &["approved", "declined"], &["approved"], &["awaiting_papers"]));
assert_eq!(m["open"], ["awaiting_papers", "approved", "declined"]);
assert_eq!(m["awaiting_papers"], ["approved", "declined"]);
}
#[test]
fn a_line_with_one_ending_has_no_way_to_be_turned_down() {
let m = machine(&need(&["open", "in_hand", "closed"], &["closed"], &["closed"], &[]));
assert_eq!(m["open"], ["in_hand"]);
assert_eq!(m["in_hand"], ["closed"]);
}
#[test]
fn words_take_the_models_strings_and_never_its_structure() {
let fallback = json!({"question": "What?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}});
let given = json!({"question": " Hva trenger du? ", "needs": {"a": {"heading": "", "labels": {"email": 7, "extra": "x"}}, "b": {"heading": "B"}}, "injected": "y"});
let merged = merge_words(&fallback, &given);
assert_eq!(merged, json!({"question": "Hva trenger du?", "needs": {"a": {"heading": "A", "labels": {"email": "Email"}}}}));
}
const PLAN: &str = "business: Nordvik Bygg\ncontact: { name: Ingrid, email: i@n.example }\nabout: We fix old buildings.\nneeds:\n - id: survey\n who: owners\n wants: Someone to look at the building\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, surveyed, quoted, won, lost]\n done_when: [won, lost]\n success: [won]\n collects:\n - { name: email, type: email }\n - { name: problem, type: textarea }\n - { name: address, optional: true }\n - id: report\n who: crew\n wants: Report a hazard\n enters_as: crew\n lands_in: site_issues\n handled_by: hse\n stages: [open, in_hand, closed]\n done_when: [closed]\n success: [closed]\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n";
#[test]
fn a_plan_renders_every_file_the_site_needs() {
let plan: Plan = serde_yaml::from_str(PLAN).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let paths: Vec<&str> = files.keys().map(String::as_str).collect();
assert_eq!(paths, [
"aggregates.yaml", "needs.yaml",
"questions/crew/_section.yaml", "questions/crew/index.yaml", "questions/crew/report-sent.yaml", "questions/crew/report.yaml",
"questions/hse/_section.yaml", "questions/hse/index.yaml",
"questions/index.yaml",
"questions/project_managers/_section.yaml", "questions/project_managers/index.yaml",
"questions/received/survey.yaml", "site.yaml",
]);
// The crew's form is gated, a way in, and recorded for the safety officer's desk.
assert!(files["questions/crew/_section.yaml"].contains("qualifies: crew"));
assert!(files["questions/crew/report.yaml"].contains("followup: false"));
assert!(files["questions/hse/index.yaml"].contains("bucket: site_issues"));
assert!(files["questions/hse/index.yaml"].contains("to: closed"));
// The spec names the heading the words gave, and the budget the form implies.
assert!(files["needs.yaml"].contains("via: Someone to look at the building"));
assert!(files["needs.yaml"].contains("max_fields: 2"));
assert!(files["needs.yaml"].contains("enters: /crew/report"));
}
#[test]
fn a_plan_with_a_bad_identifier_or_a_disagreeing_bucket_is_refused() {
let bad: Plan = serde_yaml::from_str(&PLAN.replace("lost]", "avslått]")).unwrap();
assert!(bad.validate().unwrap_err().to_string().contains("ASCII"));
let clash = format!("{PLAN} - id: other\n who: w\n wants: x\n lands_in: enquiries\n handled_by: project_managers\n stages: [open, won]\n done_when: [won]\n collects: []\n");
let plan: Plan = serde_yaml::from_str(&clash).unwrap();
assert!(plan.validate().unwrap_err().to_string().contains("must agree"));
}
}