From e3dae063d493275131890b6e71a2bd5ad00a7628 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 22:09:04 +0200 Subject: [PATCH] English in the code: fixtures, identifiers, and one phrase table The fallback mail phrases move into a `Phrases` table, the one place a language other than English appears in this code; the `navn` special case goes (a form names the person's field `name`). Test fixtures speak English: traffic, board, hazards, report, fixed. Co-Authored-By: Claude Fable 5.1 --- src/build.rs | 153 +++++++++++++++++++++++++++++++-------------------- src/plan.rs | 2 +- 2 files changed, 93 insertions(+), 62 deletions(-) diff --git a/src/build.rs b/src/build.rs index 596acfd..7a27983 100644 --- a/src/build.rs +++ b/src/build.rs @@ -741,38 +741,29 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { continue; } // The fallback is true in the plan's language before a model - // has written a word: Norwegian or English, the two the plans - // come in. - let no = plan.language.starts_with("no") || plan.language.starts_with("nb") || plan.language.starts_with("nn"); - let name_field = need.collects.iter().find(|f| f.name == "name" || f.name == "navn").map(|f| f.name.clone()); - let greeting = match (&name_field, no) { - (Some(f), true) => format!("Hei {{{f}}},\n\n"), - (None, true) => "Hei,\n\n".to_string(), - (Some(f), false) => format!("Hello {{{f}}},\n\n"), - (None, false) => "Hello,\n\n".to_string(), + // has written a word; the phrases live in one table below. + let t = Phrases::for_language(&plan.language); + let greeting = match need.collects.iter().find(|f| f.name == "name") { + Some(f) => format!("{} {{{}}},\n\n", t.hello, f.name), + None => format!("{},\n\n", t.hello), }; let mut per_stage: Map = Map::new(); for (stage, to) in &need.mails { let stage_word = humanize(stage).to_lowercase(); - let what = match (stage == &need.stages[0], no) { - (true, true) => format!("Vi har mottatt det du sendte om {}.", need.wants), - (false, true) => format!("Det du sendte om {} er nå {stage_word}.", need.wants), - (true, false) => format!("We have received what you sent about {}.", need.wants), - (false, false) => format!("What you sent about {} is now {stage_word}.", need.wants), + let what = if stage == &need.stages[0] { + format!("{} {}.", t.received_what_you_sent_about, need.wants) + } else { + format!("{} {} {} {stage_word}.", t.what_you_sent_about, need.wants, t.is_now) }; let mut body = if to == SUBMITTER { format!("{greeting}{what}") - } else if no { - format!("En sak i {} er nå {stage_word}: {{chain}}.", humanize(bucket).to_lowercase()) } else { - format!("A case in {} is now {stage_word}: {{chain}}.", humanize(bucket).to_lowercase()) + format!("{} {} {} {stage_word}: {{chain}}.", t.a_case_in, humanize(bucket).to_lowercase(), t.is_now) }; if to == SUBMITTER && moves_of(need, SUBMITTER).iter().any(|(from, _)| from == stage) { - let lead = if no { "Det du kan gjøre nå" } else { "What you can do next" }; - body.push_str(&format!("\n\n{lead}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", need.id)); + body.push_str(&format!("\n\n{}: {{site}}/decide/{}?chain={{chain}}&email={{email}}", t.what_you_can_do_next, need.id)); } - let at = if no { "på" } else { "at" }; - body.push_str(&format!("\n\n{} {at} {}", plan.contact.name, plan.contact.email)); + body.push_str(&format!("\n\n{} {} {}", plan.contact.name, t.at, plan.contact.email)); per_stage.insert(stage.clone(), json!({ "subject": format!("{}: {}", plan.business, humanize(stage)), "body": body })); } mails.insert(bucket.to_string(), Value::Object(per_stage)); @@ -813,6 +804,46 @@ fn decide_path(need: &PlanNeed) -> String { const SUBMITTER: &str = "submitter"; +/// The words of the fallback mails, per language the plans come in. +/// The only place a language other than English appears in this +/// code: everything else the site says in its own language is a word +/// slot the model or a person fills. +struct Phrases { + hello: &'static str, + received_what_you_sent_about: &'static str, + what_you_sent_about: &'static str, + is_now: &'static str, + a_case_in: &'static str, + what_you_can_do_next: &'static str, + at: &'static str, +} + +impl Phrases { + fn for_language(lang: &str) -> Phrases { + if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") { + Phrases { + hello: "Hei", + received_what_you_sent_about: "Vi har mottatt det du sendte om", + what_you_sent_about: "Det du sendte om", + is_now: "er nå", + a_case_in: "En sak i", + what_you_can_do_next: "Det du kan gjøre nå", + at: "på", + } + } else { + Phrases { + hello: "Hello", + received_what_you_sent_about: "We have received what you sent about", + what_you_sent_about: "What you sent about", + is_now: "is now", + a_case_in: "A case in", + what_you_can_do_next: "What you can do next", + at: "at", + } + } + } +} + /// Alternatives on one page before a visitor stops reading them. const MAX_FRONT_DOOR: usize = 7; @@ -1599,9 +1630,9 @@ mod tests { #[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 given = json!({"question": " What do you need? ", "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"}}}})); + assert_eq!(merged, json!({"question": "What do you need?", "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 max_fields: 4\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 max_fields: 3\n collects:\n - { name: kind, type: select, options: [Hazard, Near miss] }\n"; @@ -1731,42 +1762,42 @@ mod tests { #[test] fn selects_can_take_many_and_draw_their_options_from_the_labelled_groups() { - let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ngroups:\n - { id: trafikk, label: Trafikkforhold }\n - { id: elvesti, label: Elvesti }\nneeds:\n - id: komite\n who: en som vil sitte i en komité\n wants: en plass\n lands_in: soknader\n handled_by: styret\n stages: [open, plassert, avslatt]\n done_when: [plassert, avslatt]\n max_fields: 3\n collects:\n - { name: navn }\n - { name: komite, type: select, options_from: groups, multiple: true }\n"; + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\ngroups:\n - { id: traffic, label: Traffic }\n - { id: elvesti, label: Riverside }\nneeds:\n - id: committee\n who: someone who wants a seat on a committee\n wants: a seat\n lands_in: applications\n handled_by: board\n stages: [open, placed, declined]\n done_when: [placed, declined]\n max_fields: 3\n collects:\n - { name: name }\n - { name: committee, type: select, options_from: groups, multiple: true }\n"; let plan: Plan = serde_yaml::from_str(yaml).unwrap(); plan.validate().unwrap(); let files = render(&plan, &fallback_words(&plan)).unwrap(); let page: Value = serde_yaml::from_str(&files["questions/index.yaml"]).unwrap(); let field = &page["alternatives"][1]["features"][2]["requirements"][1]; - assert_eq!(field["options"], json!(["Trafikkforhold", "Elvesti"])); + assert_eq!(field["options"], json!(["Traffic", "Riverside"])); assert_eq!(field["multiple"], true); // Without labelled groups there is nothing to draw from. - let no_groups: Plan = serde_yaml::from_str(&yaml.replace("groups:\n - { id: trafikk, label: Trafikkforhold }\n - { id: elvesti, label: Elvesti }\n", "")).unwrap(); + let no_groups: Plan = serde_yaml::from_str(&yaml.replace("groups:\n - { id: traffic, label: Traffic }\n - { id: elvesti, label: Riverside }\n", "")).unwrap(); assert!(no_groups.validate().unwrap_err().to_string().contains("no labelled")); // `multiple` on a text field is a mistake, not a no-op. - let bad: Plan = serde_yaml::from_str(&yaml.replace("{ name: navn }", "{ name: navn, multiple: true }")).unwrap(); + let bad: Plan = serde_yaml::from_str(&yaml.replace("{ name: name }", "{ name: name, multiple: true }")).unwrap(); assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select")); } #[test] fn a_public_watcher_gets_a_read_only_page_without_contact_details() { - let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: trafikk\n who: en som har sett noe farlig\n wants: at det tas opp\n lands_in: meldinger\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n watched_by: [styret, public]\n max_fields: 2\n collects:\n - { name: hvor }\n - { name: hva, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: navn, optional: true, private: true }\n"; + let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n"; let plan: Plan = serde_yaml::from_str(yaml).unwrap(); plan.validate().unwrap(); let files = render(&plan, &fallback_words(&plan)).unwrap(); // `public` is not a group: no desk directory for it. assert!(!files.contains_key("questions/public/index.yaml")); - assert!(files.contains_key("questions/styret/index.yaml")); + assert!(files.contains_key("questions/board/index.yaml")); let view: Value = serde_yaml::from_str(&files["questions/status/meldinger/index.yaml"]).unwrap(); let resource = &view["alternatives"][0]["features"][0]["resource"]; assert_eq!(resource["public"], true); assert!(resource.get("requires_group").is_none()); - assert_eq!(resource["jq"], ".[] | .responses |= {hva: .hva, hvor: .hvor}"); + assert_eq!(resource["jq"], ".[] | .responses |= {what: .what, where: .where}"); assert!(view.get("qualifies").is_none()); // The followup page points people at the list. - let followup: Value = serde_yaml::from_str(&files["questions/received/trafikk.yaml"]).unwrap(); + let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap(); assert_eq!(followup["alternatives"][1]["action"], "/status/meldinger"); // An internal need cannot be public. - let internal: Plan = serde_yaml::from_str(&yaml.replace(" lands_in:", " enters_as: styret\n lands_in:")).unwrap(); + let internal: Plan = serde_yaml::from_str(&yaml.replace(" lands_in:", " enters_as: board\n lands_in:")).unwrap(); assert!(internal.validate().unwrap_err().to_string().contains("public view")); } @@ -2004,26 +2035,26 @@ fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std:: mod invite_tests { use super::*; - const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: trafikk, label: Trafikk }\ninvites_by: [styret]\nneeds:\n - id: melde\n who: a neighbour\n wants: a hazard seen to\n lands_in: farer\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n max_fields: 3\n collects:\n - { name: hvor }\n - id: utlegg\n who: a committee member\n wants: money back\n enters_as: trafikk\n lands_in: utlegg\n handled_by: styret\n stages: [open, betalt, avvist]\n done_when: [betalt, avvist]\n max_fields: 3\n collects:\n - { name: belop }\n"; + const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: traffic, label: Trafikk }\ninvites_by: [board]\nneeds:\n - id: report\n who: a neighbour\n wants: a hazard seen to\n lands_in: hazards\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 3\n collects:\n - { name: where }\n - id: expenses\n who: a committee member\n wants: money back\n enters_as: traffic\n lands_in: expenses\n handled_by: board\n stages: [open, betalt, avvist]\n done_when: [betalt, avvist]\n max_fields: 3\n collects:\n - { name: amount }\n"; #[test] fn words_follow_a_card_that_moves_to_another_page() { // Built once behind a door and hand-worded; rebuilt flat, the // form's words come along even though its page is gone. - let doored = "business: Vel\ncontact: { name: S, email: s@v.example }\ndoors:\n - { id: melde-fra, who: someone who saw something }\nneeds:\n - id: melde\n who: a neighbour\n wants: a hazard seen to\n door: melde-fra\n lands_in: farer\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n max_fields: 3\n collects:\n - { name: hvor }\n"; + let doored = "business: Vel\ncontact: { name: S, email: s@v.example }\ndoors:\n - { id: report-something, who: someone who saw something }\nneeds:\n - id: report\n who: a neighbour\n wants: a hazard seen to\n door: report-something\n lands_in: hazards\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n max_fields: 3\n collects:\n - { name: where }\n"; let plan: Plan = serde_yaml::from_str(doored).unwrap(); let mut words = fallback_words(&plan); - let door_page = words.get_mut("questions/melde-fra/index.yaml").unwrap(); - door_page["needs"]["melde"]["heading"] = json!("Meld fra om en trafikkfare"); - door_page["needs"]["melde"]["hints"]["hvor"] = json!("Svingen ved skolen"); + let door_page = words.get_mut("questions/report-something/index.yaml").unwrap(); + door_page["needs"]["report"]["heading"] = json!("Report a traffic hazard"); + door_page["needs"]["report"]["hints"]["where"] = json!("The bend by the school"); let repo = std::env::temp_dir().join(format!("trainer-lift-{}", std::process::id())); let _ = std::fs::remove_dir_all(&repo); write_repo(&repo, render(&plan, &words).unwrap()).unwrap(); - let flat: Plan = serde_yaml::from_str(&doored.replace("doors:\n - { id: melde-fra, who: someone who saw something }\n", "").replace(" door: melde-fra\n", "")).unwrap(); + let flat: Plan = serde_yaml::from_str(&doored.replace("doors:\n - { id: report-something, who: someone who saw something }\n", "").replace(" door: report-something\n", "")).unwrap(); let lifted = lift_words(&flat, &repo).unwrap(); - assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["heading"], "Meld fra om en trafikkfare"); - assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["hints"]["hvor"], "Svingen ved skolen"); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], "Report a traffic hazard"); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["hints"]["where"], "The bend by the school"); let _ = std::fs::remove_dir_all(&repo); } @@ -2032,15 +2063,15 @@ mod invite_tests { let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); plan.validate().unwrap(); let files = render(&plan, &fallback_words(&plan)).unwrap(); - // The board invites into both groups; trafikk only into itself. - let styret = &files["questions/styret/index.yaml"]; - assert_eq!(styret.matches("invite:").count(), 2, "{styret}"); - assert!(styret.contains("bucket: portal_invites")); - let trafikk = &files["questions/trafikk/index.yaml"]; - assert_eq!(trafikk.matches("invite:").count(), 1); - assert!(trafikk.contains("group: trafikk") && !trafikk.contains("group: styret")); + // The board invites into both groups; traffic only into itself. + let board = &files["questions/board/index.yaml"]; + assert_eq!(board.matches("invite:").count(), 2, "{board}"); + assert!(board.contains("bucket: portal_invites")); + let traffic = &files["questions/traffic/index.yaml"]; + assert_eq!(traffic.matches("invite:").count(), 1); + assert!(traffic.contains("group: traffic") && !traffic.contains("group: board")); // The plan is refused when an inviter has no desk. - let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [styret]", "invites_by: [nobody]")).unwrap(); + let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [nobody]")).unwrap(); assert!(bad.validate().unwrap_err().to_string().contains("no desk")); } @@ -2049,23 +2080,23 @@ mod invite_tests { let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); let mut words = fallback_words(&plan); // A hand-edited desk: Norwegian words in the live repo. - let desk = words.get_mut("questions/trafikk/index.yaml").unwrap(); - desk["question"] = json!("Hva må gjøres?"); - desk["buckets"]["farer"]["heading"] = json!("Meldte farer"); - desk["buckets"]["farer"]["moves"]["open->ordnet"] = json!("Ordnet"); + let desk = words.get_mut("questions/traffic/index.yaml").unwrap(); + desk["question"] = json!("What is waiting?"); + desk["buckets"]["hazards"]["heading"] = json!("Reported hazards"); + desk["buckets"]["hazards"]["moves"]["open->fixed"] = json!("Fixed"); let dir = std::env::temp_dir().join(format!("lift-{}", std::process::id())); write_repo(&dir, render(&plan, &words).unwrap()).unwrap(); let lifted = lift_words(&plan, &dir).unwrap(); - let desk = &lifted["questions/trafikk/index.yaml"]; - assert_eq!(desk["question"], "Hva må gjøres?"); - assert_eq!(desk["buckets"]["farer"]["heading"], "Meldte farer"); - assert_eq!(desk["buckets"]["farer"]["moves"]["open->ordnet"], "Ordnet"); + let desk = &lifted["questions/traffic/index.yaml"]; + assert_eq!(desk["question"], "What is waiting?"); + assert_eq!(desk["buckets"]["hazards"]["heading"], "Reported hazards"); + assert_eq!(desk["buckets"]["hazards"]["moves"]["open->fixed"], "Fixed"); // A slot the repo never had keeps the fallback. - let plan2: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [styret]", "invites_by: [styret, trafikk]")).unwrap(); + let plan2: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [board]", "invites_by: [board, traffic]")).unwrap(); let lifted2 = lift_words(&plan2, &dir).unwrap(); - assert_eq!(lifted2["questions/trafikk/index.yaml"]["question"], "Hva må gjøres?"); - assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone")); + assert_eq!(lifted2["questions/traffic/index.yaml"]["question"], "What is waiting?"); + assert!(lifted2["questions/traffic/index.yaml"]["invites"]["board"]["heading"].as_str().unwrap().starts_with("Invite someone")); let _ = std::fs::remove_dir_all(&dir); } @@ -2073,7 +2104,7 @@ mod invite_tests { fn words_follow_their_card_when_the_live_page_is_ordered_differently() { let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); let mut words = fallback_words(&plan); - words.get_mut("questions/index.yaml").unwrap()["needs"]["melde"]["heading"] = json!("Meld fra om en fare"); + words.get_mut("questions/index.yaml").unwrap()["needs"]["report"]["heading"] = json!("Report a hazard"); let dir = std::env::temp_dir().join(format!("lift-order-{}", std::process::id())); write_repo(&dir, render(&plan, &words).unwrap()).unwrap(); // The live front door has an extra first card the plan knows @@ -2086,7 +2117,7 @@ mod invite_tests { std::fs::write(&front, serde_yaml::to_string(&page).unwrap()).unwrap(); let lifted = lift_words(&plan, &dir).unwrap(); - assert_eq!(lifted["questions/index.yaml"]["needs"]["melde"]["heading"], "Meld fra om en fare"); + assert_eq!(lifted["questions/index.yaml"]["needs"]["report"]["heading"], "Report a hazard"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/plan.rs b/src/plan.rs index 98a9258..db1c145 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -37,7 +37,7 @@ doors: # a first question before the forms, when more tha who: who comes through it # per kind of visitor, two or three needs behind each groups: # groups with a label the public may see: the committees - - { id: trafikk, label: Trafikkforhold } # a person can ask to join (optional) + - { id: traffic, label: Traffic } # a person can ask to join (optional) needs: - id: kebab-case # ASCII only