Portal 0.5 in the plan, mails scored as read, and a front door that shows

Portal 0.5's content, from the plan: `grants` (an approval that is
membership; refused without a mail sender, since the sign-in link goes
by mail), `deadlines` (portal's `after:`, only along a move the
machine has), and `public_states` with an `empty` line on public lists,
whose words now say which stages they show.

Onboarding is a mail too: a plan that invites or grants gets
`mail.invite` in site.yaml in its own language, and a reword that loses
{link} gets it back. `rate --repo` rates every mail, the invite
included, read as it arrives: LIX, and what happened / what now / who
wrote, each against a gutted twin. The mail words are guided by what
that found on uhhm (name the case by a short required field, sign with
the business, invite a reply rather than promise a reason).

The front door shows public lists instead of pointing at them: each
public bucket is a feature with its newest three records inline and a
link to the whole list. Its card is named for what it shows. The plan
reference gains 'What a portal site can do': the schema's options and
when each pays off, as guidance rather than rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-28 20:34:53 +02:00
co-authored by Claude Opus 5.5
parent 040a95ee07
commit d4d4c7cba3
4 changed files with 399 additions and 46 deletions
+249 -35
View File
@@ -253,6 +253,24 @@ pub struct PlanNeed {
/// case moves; the words model writes the subject and body.
#[serde(default)]
pub mails: BTreeMap<String, String>,
/// A stage that makes the person a member of a group when their
/// case arrives there (portal's `grants:`): an approval that means
/// membership, with no second step to forget. The form must then
/// collect a required `name` and `email`, since by the time a desk
/// presses approve there is nobody left to ask.
#[serde(default)]
pub grants: BTreeMap<String, String>,
/// A stage a clock moves on from (portal's `after:`): a case left
/// in it that many days, measured from its last move, goes on to
/// `to`. Only along a move the state machine already has; a clock
/// may do only what a person could have done.
#[serde(default)]
pub deadlines: BTreeMap<String, Deadline>,
/// With `watched_by: [public]`, the stages the public list shows;
/// empty shows all. A noticeboard shows what is up, not what was
/// taken down; a register shows what was decided.
#[serde(default)]
pub public_states: Vec<String>,
pub collects: Vec<Field>,
/// How many required fields this person will tolerate: what the
/// business said about them, not what the form happens to ask.
@@ -292,6 +310,13 @@ impl Field {
}
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Deadline {
pub days: u32,
pub to: String,
}
/// The name a plan uses in `watched_by` for everyone: a read-only page
/// of the bucket, no login.
pub const PUBLIC: &str = "public";
@@ -449,6 +474,37 @@ impl Plan {
bail!(at("has mails, so the plan needs `mail: { from: ... }` saying who they come from"));
}
}
for (state, group) in &need.grants {
if self.mail.is_none() {
bail!(at("grants a group, so the plan needs `mail: { from: ... }`: the new member's sign-in link is sent by mail, and without a sender it reaches nobody"));
}
if !need.stages[1..].contains(state) {
bail!(at(&format!("grants names {state:?}, which is not a stage a case can move into")));
}
if !is_id(group) || group == PUBLIC || group == SUBMITTER {
bail!(at(&format!("grants {state:?} must name a group, not {group:?}")));
}
let required = |name: &str, kind: &str| need.collects.iter().any(|f| f.name == name && f.kind == kind && !f.optional);
if !required("name", "text") || !required("email", "email") {
bail!(at("a stage that grants a group makes an account for the person, so the form must collect a required `name` (text) and a required `email` (type email)"));
}
}
for (state, d) in &need.deadlines {
if d.days == 0 {
bail!(at(&format!("deadline on {state:?} must wait at least one day")));
}
if !machine(need).get(state).is_some_and(|tos| tos.contains(&d.to)) {
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
}
}
if !need.public_states.is_empty() {
if !need.watched_by.iter().any(|g| g == PUBLIC) {
bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by"));
}
if let Some(bad) = need.public_states.iter().find(|s| !need.stages.contains(s)) {
bail!(at(&format!("public_states names {bad:?}, which is not one of its stages")));
}
}
for (state, text) in &need.stages_text {
if !need.stages.contains(state) {
bail!(at(&format!("stages_text names {state:?}, which is not one of its stages")));
@@ -518,11 +574,14 @@ impl Plan {
|| other.events != first.events
|| other.attended_by != first.attended_by
|| other.mails != first.mails
|| other.grants != first.grants
|| other.deadlines != first.deadlines
|| other.public_states != first.public_states
|| other.stages_text != first.stages_text
|| other.turned_down_from != first.turned_down_from
|| other.reached_from != first.reached_from
{
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails and who moves and watches it", first.id, other.id);
bail!("needs {:?} and {:?} both land in {bucket:?} and must agree on stages, done_when, skippable, events, attended_by, mails, grants, deadlines, public_states and who moves and watches it", first.id, other.id);
}
}
}
@@ -755,8 +814,10 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
shopping.insert(
format!("status-{bucket}"),
json!({
"heading": format!("See all {}", humanize(bucket).to_lowercase()),
"description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
"heading": format!("Latest {}", humanize(bucket).to_lowercase()),
"description": public_blurb(&needs),
"link": format!("All {}", humanize(bucket).to_lowercase()),
"empty": "Nothing here yet.",
"icon": "list",
}),
);
@@ -767,7 +828,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
"question": "What brings you here?",
"description": plan.about,
"opener": { "heading": "What we do", "description": "", "features": does },
"shopping": { "heading": "Have a look around", "description": "", "items": shopping },
// Named for what it shows, never an invitation to look.
"shopping": { "heading": format!("Latest from {}", plan.business), "description": "", "items": shopping },
// A door's feature says what is behind it, from the needs
// that sit there: a card that leads somewhere has to say
// where, or it is a button with decoration around it.
@@ -880,18 +942,25 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
public_view_path(bucket),
json!({
"question": format!("What has been sent in about {}?", humanize(bucket).to_lowercase()),
"description": format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
"description": public_blurb(&needs),
"heading": humanize(bucket),
"list_description": "",
// A public list says what a row is and what it leaves
// out, since the reader cannot know what was pruned.
"list_title": humanize(bucket),
"list_note": "Each row is one case and the stage it has reached. Names and contact details are not shown here.",
"empty": "Nothing here yet.",
"back": "Back",
}),
);
}
// The invite mail, when people are brought in through the site
// and it sends mail: the first thing a new member reads from it.
if plan.mail.is_some() && has_onboarding(plan) {
words.insert("site.yaml".to_string(), json!({ "invite": invite_fallback(&plan.language) }));
}
// The mails: one subject and body per stage that sends one, keyed
// by bucket. Templates in portal's terms: {field} for a field of
// the record, {site}, {chain}, {email}. The fallback says what
@@ -957,6 +1026,43 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
words
}
/// Whether anyone is brought in through the site: invites from a desk,
/// or a stage that grants a group. Both send portal's invite mail.
fn has_onboarding(plan: &Plan) -> bool {
!plan.invites_by.is_empty() || plan.groups.iter().any(|g| !g.invited_by.is_empty()) || plan.needs.iter().any(|n| !n.grants.is_empty())
}
/// The invite mail's fallback words, in the plan's language: the
/// person, the group, the one-time link and when it runs out. Portal
/// fills `{name}`, `{username}`, `{email}`, `{group}`, `{link}`,
/// `{site}`, `{site_name}` and `{expires}`.
fn invite_fallback(lang: &str) -> Value {
if lang.starts_with("no") || lang.starts_with("nb") || lang.starts_with("nn") {
json!({
"subject": "Din konto hos {site_name}",
"body": "Hei {name},\n\n{site_name} har laget en konto til deg og lagt deg til i {group}. Brukernavnet ditt er {username}.\n\nVelg passord her før {expires}:\n{link}\n\nEtterpå logger du inn på {site}.",
})
} else {
json!({
"subject": "Your account at {site_name}",
"body": "Hello {name},\n\n{site_name} has made you an account and added you to {group}. Your username is {username}.\n\nSet your password here before {expires}:\n{link}\n\nAfter that you sign in at {site}.",
})
}
}
/// The invite mail without its link cannot be used: a model's reword
/// that dropped `{link}` gets the fallback's link line back.
fn keep_invite_link(lang: &str, merged: &mut Value) {
let Some(body) = merged.pointer_mut("/invite/body") else { return };
let Some(text) = body.as_str() else { return };
if text.contains("{link}") {
return;
}
let fallback = invite_fallback(lang);
let link_line = s(&fallback["body"]).lines().filter(|l| l.contains("{link}") || l.contains("{expires}")).collect::<Vec<_>>().join("\n");
*body = Value::String(format!("{}\n\n{link_line}", text.trim_end()));
}
fn door_path(door: &Door) -> String {
format!("questions/{}/index.yaml", door.id)
}
@@ -1067,6 +1173,54 @@ fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> {
plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect()
}
/// The stages a public list is limited to, in words: "approved", or
/// "approved or refused". Empty when it shows every stage.
fn shown_stages(need: &PlanNeed) -> String {
let words: Vec<String> = need.public_states.iter().map(|st| stage_text(need, st)).collect();
match words.len() {
0 => String::new(),
1 => words[0].clone(),
n => format!("{} or {}", words[..n - 1].join(", "), words[n - 1]),
}
}
/// How many of a public list's newest records the front door shows.
const FRONT_DOOR_LATEST: usize = 3;
/// The read-only resource over a public bucket: newest first, only the
/// stages the plan opens, contact details and private fields pruned,
/// and at most `limit` records when given. Portal filters by `states`
/// while listing, before `jq` runs, so the limit counts shown records.
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>) -> Value {
let mut shown: BTreeSet<&str> = BTreeSet::new();
for n in needs {
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
}
let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default();
// One record per yielded value: portal collects what the filter
// yields into the list, so `map` would nest it once more
// (measured: an empty bucket rendered as `[[]]`).
let jq = format!(
"sort_by(.submitted_ms) | reverse{take} | .[] | .responses |= {{{}}}",
shown.iter().map(|f| format!("{f}: .{f}")).collect::<Vec<_>>().join(", ")
);
json!({
"source": { "kind": "kv", "bucket": bucket },
"public": true,
"states": needs[0].public_states,
"empty": empty,
"jq": jq,
})
}
/// What a public list holds, in one sentence, honest about its filter.
fn public_blurb(needs: &[&PlanNeed]) -> String {
match shown_stages(needs[0]).as_str() {
"" => format!("Everything sent in by {}, and where each one stands.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
only => format!("What {} sent in that is now {only}.", needs.iter().map(|n| n.who.as_str()).collect::<Vec<_>>().join(" or ")),
}
}
fn public_view_path(bucket: &str) -> String {
format!("questions/status/{bucket}/index.yaml")
}
@@ -1200,6 +1354,8 @@ fn slots_in(value: &Value, key: &str, parent: &str) -> Value {
(_, "who_title") => "a short title, two to four words, for the line that says who reads it".to_string(),
(_, "list_description") => "one line over a public list of what people have sent in, or empty".to_string(),
(_, "list_title") => "a short title, two to four words, naming the list itself".to_string(),
(_, "link") => "the words of a link to the whole list, two to four words, naming it".to_string(),
(_, "empty") => "one short sentence shown when the list has nothing in it yet, inviting rather than apologising".to_string(),
(_, "list_note") => "one sentence saying what a single row is, and what the buttons under it do or what the list leaves out".to_string(),
(_, "behind") => "one sentence naming what a reader can do behind this door, in their words".to_string(),
(_, "who_description") => "one sentence: who reads what they send, and how to reach them meanwhile".to_string(),
@@ -1309,6 +1465,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
if let Some(reply_to) = &mail.reply_to {
site["mail"]["reply_to"] = json!(reply_to);
}
if let Some(invite) = words.get("site.yaml").map(|w| &w["invite"]).filter(|i| i.is_object()) {
site["mail"]["invite"] = json!({ "subject": s(&invite["subject"]), "body": s(&invite["body"]) });
}
}
files.insert("site.yaml".into(), site);
@@ -1326,6 +1485,12 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
let w = &mail_words[bucket][st];
state["mail"] = json!({ "to": to, "subject": s(&w["subject"]), "body": s(&w["body"]) });
}
if let Some(group) = need.grants.get(st) {
state["grants"] = json!(group);
}
if let Some(d) = need.deadlines.get(st) {
state["after"] = json!({ "days": d.days, "to": d.to });
}
(st.clone(), state)
}).collect::<Map<_, _>>(),
"transitions": machine(need),
@@ -1365,9 +1530,14 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
f
})
.collect();
for (bucket, _) in public_buckets(plan) {
let mut f = card(&front["shopping"]["items"][&format!("status-{bucket}")]);
f["description"] = json!(format!("{} [{}](/status/{bucket})", s(&f["description"]).trim(), s(&front["shopping"]["items"][&format!("status-{bucket}")]["heading"])));
// A public list is shown, not pointed at: its newest few records
// inline, and a link to the whole list.
for (bucket, needs) in public_buckets(plan) {
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
let mut f = card(w);
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim(), l => l };
f["description"] = json!(format!("{} [{link}](/status/{bucket})", s(&w["description"]).trim()).trim().to_string());
f["resource"] = public_resource(bucket, &needs, s(&w["empty"]), Some(FRONT_DOOR_LATEST));
shopping.push(f);
}
if !shopping.is_empty() {
@@ -1549,19 +1719,6 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
for (bucket, needs) in public_buckets(plan) {
let path = public_view_path(bucket);
let w = &words[&path];
let mut shown: BTreeSet<&str> = BTreeSet::new();
for n in &needs {
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
}
// Records keep their shape (state, timestamps) so portal
// renders them as records; only the answers are pruned. One
// record per yielded value: portal collects what the filter
// yields into the list, so `map` would nest it once more
// (measured: an empty bucket rendered as `[[]]`).
let projection = format!(
".[] | .responses |= {{{}}}",
shown.iter().map(|f| format!("{f}: .{f}")).collect::<Vec<_>>().join(", ")
);
files.insert(
path,
json!({
@@ -1572,11 +1729,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
{
"name": s(&w["heading"]),
"description": s(&w["list_description"]),
"features": [{ "name": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": {
"source": { "kind": "kv", "bucket": bucket },
"public": true,
"jq": projection,
}}],
"features": [{ "name": s(&w["list_title"]), "description": s(&w["list_note"]), "resource": public_resource(bucket, &needs, s(&w["empty"]), None) }],
},
{ "name": s(&w["back"]), "description": "", "action": "/", "consequence": [s(&w["back"])] },
],
@@ -1651,14 +1804,26 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
}
fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
let purpose = if path == "aggregates.yaml" {
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it."
let shown_only = public_buckets(plan)
.into_iter()
.find(|(bucket, _)| public_view_path(bucket) == path)
.map(|(_, needs)| shown_stages(needs[0]))
.unwrap_or_default();
let status_purpose = if shown_only.is_empty() {
"a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list.".to_string()
} else {
format!("a public page anyone can open, read-only, listing ONLY the cases that have reached: {shown_only}. Nothing else is shown - do not promise the reader anything waiting or turned down. `question` is what this page asks; `heading` names the list.")
};
let purpose = if path == "site.yaml" {
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
} else if path == "aggregates.yaml" {
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation."
} else if path.contains("/decide/") {
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
} else if path == "questions/index.yaml" {
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card, things a visitor may look at before asking for anything. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
} else if path.starts_with("questions/status/") {
"a public page anyone can open, listing everything sent in to one bucket and where each one stands, read-only. `question` is what this page asks; `heading` names the list."
status_purpose.as_str()
} else if plan.doors.iter().any(|d| door_path(d) == path) {
"a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it."
} else if path.ends_with("/index.yaml") {
@@ -1676,7 +1841,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String {
context.extend(
plan.needs
.iter()
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "))),
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
);
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- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.\n\n{}\n\nReply with the JSON object only.",
@@ -1765,6 +1930,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
keep_links(&fallback_words(&plan)["aggregates.yaml"], &mut mails);
words.insert("aggregates.yaml".to_string(), mails);
}
if let Some(site_words) = words.get_mut("site.yaml") {
keep_invite_link(&plan.language, site_words);
}
write_repo(&out, render(&plan, &words)?)?;
// The words as used, so the next rebuild can start from them.
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
@@ -1815,6 +1983,7 @@ mod tests {
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
moves_by: BTreeMap::new(), watched_by: vec![],
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(),
grants: BTreeMap::new(), deadlines: BTreeMap::new(), public_states: vec![],
collects: vec![], max_fields: 3,
}
}
@@ -2120,11 +2289,17 @@ mod tests {
assert!(alts[0].get("action").is_none());
assert_eq!(alts[0]["features"].as_array().unwrap().len(), 2);
assert_eq!(alts[0]["features"][0]["description"], "We fix roofs.");
// The look around: the resource verbatim, and the public view linked.
assert_eq!(alts[1]["name"], "Have a look around");
// The live content, named for what it is: the resource verbatim,
// and the public list shown inline - its newest three - with a
// link to the whole of it.
assert!(alts[1]["name"].as_str().unwrap().starts_with("Latest from"));
assert!(alts[1].get("action").is_none());
assert_eq!(alts[1]["features"][0]["resource"]["source"]["kind"], "url");
assert!(alts[1]["features"][1]["description"].as_str().unwrap().contains("](/status/leaks)"));
let listed = &alts[1]["features"][1];
assert!(listed["description"].as_str().unwrap().contains("](/status/leaks)"));
assert_eq!(listed["resource"]["source"]["bucket"], "leaks");
assert_eq!(listed["resource"]["public"], true);
assert!(listed["resource"]["jq"].as_str().unwrap().starts_with("sort_by(.submitted_ms) | reverse | .[:3] | .[] |"));
// Only then the offers.
assert_eq!(alts[2]["record_as"], "leaks");
assert_eq!(alts[3]["record_as"], "applicants");
@@ -2207,7 +2382,7 @@ mod tests {
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 |= {what: .what, where: .where}");
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .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/traffic.yaml"]).unwrap();
@@ -2217,6 +2392,45 @@ mod tests {
assert!(internal.validate().unwrap_err().to_string().contains("public view"));
}
#[test]
fn a_stage_can_grant_a_group_a_clock_can_move_a_case_and_a_public_list_can_show_some_stages() {
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\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 grants: { plassert: komiteer }\n max_fields: 3\n collects:\n - { name: name }\n - { name: email, type: email }\n - id: oppslag\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: styret\n stages: [open, lagt_ut, tatt_ned]\n done_when: [tatt_ned]\n watched_by: [public]\n public_states: [lagt_ut]\n deadlines: { lagt_ut: { days: 30, to: tatt_ned } }\n max_fields: 2\n collects:\n - { name: hva, type: textarea }\n";
let plan: Plan = serde_yaml::from_str(yaml).unwrap();
plan.validate().unwrap();
let files = render(&plan, &fallback_words(&plan)).unwrap();
let agg: Value = serde_yaml::from_str(&files["aggregates.yaml"]).unwrap();
let bucket = |name: &str| agg["aggregates"].as_array().unwrap().iter().find(|a| a["bucket"] == name).unwrap().clone();
assert_eq!(bucket("soknader")["states"]["plassert"]["grants"], "komiteer");
assert_eq!(bucket("oppslag")["states"]["lagt_ut"]["after"], json!({"days": 30, "to": "tatt_ned"}));
let view: Value = serde_yaml::from_str(&files["questions/status/oppslag/index.yaml"]).unwrap();
let resource = &view["alternatives"][0]["features"][0]["resource"];
assert_eq!(resource["states"], json!(["lagt_ut"]));
assert_eq!(resource["empty"], "Nothing here yet.");
// A new member's sign-in link goes by mail: the site writes its
// invite mail, and a reword that loses the link gets it back.
let site: Value = serde_yaml::from_str(&files["site.yaml"]).unwrap();
assert!(s(&site["mail"]["invite"]["body"]).contains("{link}"));
let mut words = fallback_words(&plan);
words.insert("site.yaml".into(), json!({ "invite": { "subject": "Velkommen", "body": "Hei {name}, du er med i {group}." } }));
keep_invite_link("no", words.get_mut("site.yaml").unwrap());
let body = s(&words["site.yaml"]["invite"]["body"]).to_string();
assert!(body.starts_with("Hei {name}, du er med i {group}.") && body.contains("{link}") && body.contains("{expires}"), "{body}");
// Without a sender, a grant is refused: the link would reach nobody.
let no_sender: Plan = serde_yaml::from_str(&yaml.replace("mail: { from: post@b.example }\n", "")).unwrap();
assert!(no_sender.validate().unwrap_err().to_string().contains("reaches nobody"));
// A grant needs the person's name and address on the form.
let no_name: Plan = serde_yaml::from_str(&yaml.replace(" - { name: name }\n", "")).unwrap();
assert!(no_name.validate().unwrap_err().to_string().contains("required `name`"));
// A clock may only make a move the machine has.
let off_graph: Plan = serde_yaml::from_str(&yaml.replace("{ days: 30, to: tatt_ned }", "{ days: 30, to: open }")).unwrap();
assert!(off_graph.validate().unwrap_err().to_string().contains("not a move"));
// public_states without a public list says nothing.
let not_public: Plan = serde_yaml::from_str(&yaml.replace(" watched_by: [public]\n", "")).unwrap();
assert!(not_public.validate().unwrap_err().to_string().contains("no `public`"));
}
#[test]
fn a_form_that_asks_more_than_the_person_will_tolerate_is_refused() {
// Two required fields under a budget of one: the plan is refused