Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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);
    }
}