crates/gunnar-ui-web/src/actions.rs
//! **Åtgärder** — the decisions behind the queue, host-side so they are
//! testable.
//!
//! The server decides WHAT is in the queue and what the headline says; this
//! decides how loudly to draw it and when to draw nothing at all. Both halves
//! being able to answer "is anything wrong" is deliberate — the headline is
//! computed once, on the server, so a banner and the list under it can never
//! disagree — and everything here is about rendering that one answer.
//!
//! Same split as [`crate::room`], [`crate::twin`] and [`crate::machine`]: the
//! browser draws, this decides, and `cargo test` can reach it.
use serde::Deserialize;
/// How much of a hurry an item is in, as the server spells it.
///
/// An unknown word from a newer server is [`Urgency::Info`] and NOT urgent: a
/// console that guessed the other way would learn to shout at a version it does
/// not understand, and the first thing anybody would do is stop reading the
/// banner.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum Urgency {
/// Worth knowing; nothing is wrong.
Info,
/// Will become urgent if ignored.
Soon,
/// Already broken, or about to stop working.
Urgent,
/// **A word this console does not know**, from a newer server.
///
/// Last, because `#[serde(other)]` has to be — which is why the ordering
/// below is an explicit rank rather than a derive. A derived `Ord` would
/// make the unknown rung the LOUDEST one purely because of where serde
/// requires it to sit, which is the exact opposite of what it should mean.
#[default]
#[serde(other)]
Unknown,
}
impl Urgency {
/// How loud, as a number. `Unknown` ranks with `Info`: quiet.
pub fn rank(self) -> u8 {
match self {
Urgency::Unknown | Urgency::Info => 0,
Urgency::Soon => 1,
Urgency::Urgent => 2,
}
}
}
/// One thing to do, as `/api/account/actions` sends it.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct Action {
/// The stable slug. Keyed off instead of the title, so rewording an item on
/// the server cannot silently change what a page does with it.
pub id: String,
#[serde(default)]
pub urgency: Urgency,
pub title: String,
#[serde(default)]
pub detail: String,
#[serde(default)]
pub href: String,
#[serde(default)]
pub cta: String,
}
/// The whole answer.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
pub struct Actions {
#[serde(default)]
pub items: Vec<Action>,
/// The one line the bar carries, or `None` when nothing needs saying.
#[serde(default)]
pub headline: Option<String>,
}
/// What the top bar should do with an answer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Bar {
/// Draw nothing. The ordinary state, and the one that keeps the bar worth
/// reading on the day it does appear.
Silent,
/// Draw the line, in this tone, linking here.
Show { line: String, urgent: bool, href: String },
}
/// **Whether the bar appears, and how loud.**
///
/// It appears only when the SERVER sent a headline. The console does not
/// compose one from the items: two places deciding what is most pressing is two
/// places that can disagree, and the one the reader sees would be the one
/// written by whoever last edited the browser.
///
/// The tone follows the worst item rather than the headline's text, because the
/// text is prose and the urgency is a value.
pub fn bar(answer: &Actions) -> Bar {
let Some(line) = answer.headline.as_ref().filter(|l| !l.is_empty()) else {
return Bar::Silent;
};
let worst = answer.items.iter().map(|i| i.urgency.rank()).max().unwrap_or(0);
// A headline with no item behind it is still shown — the server said
// something is wrong and it is not this console's place to overrule it —
// but it is drawn at the quieter tone, because there is nothing to point at
// that would justify the louder one.
let href = answer
.items
.iter()
.max_by_key(|i| i.urgency.rank())
.map(|i| i.href.clone())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "/account".to_owned());
Bar::Show { line: line.clone(), urgent: worst >= Urgency::Urgent.rank(), href }
}
/// The items worth listing on a page, worst first.
///
/// The server already sorts; this re-sorts anyway, because a panel that
/// depended on the server's order would be a panel that quietly scrambles the
/// day somebody adds an item on the far side.
pub fn listed(answer: &Actions) -> Vec<Action> {
let mut items = answer.items.clone();
items.sort_by_key(|i| std::cmp::Reverse(i.urgency.rank()));
items
}
#[cfg(test)]
mod tests {
use super::*;
fn act(id: &str, urgency: Urgency, href: &str) -> Action {
Action {
id: id.to_owned(),
urgency,
title: format!("{id} title"),
detail: String::new(),
href: href.to_owned(),
cta: String::new(),
}
}
/// The ordinary state is silence. A bar that said "all good" on every page
/// is a bar nobody reads on the day it says something else.
#[test]
fn no_headline_is_no_bar() {
assert_eq!(bar(&Actions::default()), Bar::Silent);
let only_items = Actions { items: vec![act("x", Urgency::Urgent, "/account")], headline: None };
assert_eq!(bar(&only_items), Bar::Silent, "the browser composed its own headline");
}
/// The tone follows the worst ITEM, not the prose, and the link goes to the
/// thing that earned the tone.
#[test]
fn the_bar_takes_its_tone_and_its_door_from_the_worst_item() {
let a = Actions {
items: vec![act("soon", Urgency::Soon, "/account"), act("bad", Urgency::Urgent, "/admin")],
headline: Some("something".to_owned()),
};
assert_eq!(
bar(&a),
Bar::Show { line: "something".to_owned(), urgent: true, href: "/admin".to_owned() }
);
}
/// Only `Soon` in the queue is a quieter bar — still shown, not shouted.
#[test]
fn a_queue_with_nothing_urgent_is_drawn_quietly() {
let a = Actions {
items: vec![act("soon", Urgency::Soon, "/account")],
headline: Some("later".to_owned()),
};
assert!(matches!(bar(&a), Bar::Show { urgent: false, .. }));
}
/// ★ **An urgency word this console does not know is not urgent.** A newer
/// server may add a rung; a browser that guessed "shout" would learn to
/// shout at a version it cannot read, and the banner would stop being read
/// at all.
#[test]
fn an_unknown_urgency_is_the_quiet_one() {
let parsed: Actions = serde_json::from_str(
r#"{"items":[{"id":"x","urgency":"catastrophic","title":"t","href":"/account"}],
"headline":"t"}"#,
)
.expect("a newer server's answer still parses");
assert_eq!(parsed.items[0].urgency, Urgency::Unknown);
assert_eq!(parsed.items[0].urgency.rank(), Urgency::Info.rank(), "an unknown rung is not quiet");
assert!(matches!(bar(&parsed), Bar::Show { urgent: false, .. }));
}
/// A headline the server sent with no items behind it is still shown — it
/// is not this console's place to overrule the server about whether
/// something is wrong — but quietly, and pointing somewhere that exists.
#[test]
fn a_headline_with_no_items_still_shows_and_lands_somewhere() {
let a = Actions { items: Vec::new(), headline: Some("hm".to_owned()) };
assert_eq!(
bar(&a),
Bar::Show { line: "hm".to_owned(), urgent: false, href: "/account".to_owned() }
);
}
/// The panel sorts for itself rather than trusting the wire's order.
#[test]
fn the_list_is_worst_first_whatever_order_it_arrived_in() {
let a = Actions {
items: vec![
act("info", Urgency::Info, "/account"),
act("urgent", Urgency::Urgent, "/account"),
act("soon", Urgency::Soon, "/account"),
],
headline: None,
};
let ids: Vec<String> = listed(&a).into_iter().map(|i| i.id).collect();
assert_eq!(ids, ["urgent", "soon", "info"]);
}
/// The answer shape is the server's, including the fields an older one
/// might not send.
#[test]
fn a_sparse_answer_parses() {
let a: Actions = serde_json::from_str(r#"{"items":[{"id":"x","title":"t"}]}"#).expect("parses");
assert_eq!(a.items[0].urgency, Urgency::Unknown);
assert!(a.items[0].href.is_empty() && a.headline.is_none());
assert_eq!(bar(&a), Bar::Silent);
}
}