use rustbot::i18n::Locale; use rustbot::karma_web::{parse_board, parse_events, parse_meta, render, Channel}; fn board_with_events(net: &str, store: &str, log: &str) -> rustbot::karma_web::Board { let mut b = parse_board(net, store); b.events = parse_events(log); b } #[test] fn parses_flat_number_format() { let b = parse_board("libera", r#"{"romaka":2}"#); assert_eq!(b.rows.len(), 1); assert_eq!(b.rows[0].thing, "romaka"); assert_eq!(b.rows[0].count, 2); } #[test] fn parses_object_format_with_reason_and_by() { let b = parse_board("t", r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#); assert_eq!(b.rows[0].count, 5); assert_eq!(b.rows[0].reason.as_deref(), Some("clean PR")); assert_eq!(b.rows[0].by.as_deref(), Some("alice")); } #[test] fn sorts_descending_by_count() { let b = parse_board("t", r#"{"a":1,"b":9,"c":-3}"#); let order: Vec<&str> = b.rows.iter().map(|r| r.thing.as_str()).collect(); assert_eq!(order, vec!["b", "a", "c"]); } #[test] fn parses_event_log_lines() { let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\ {\"t\":200,\"o\":\"rust\",\"d\":-1,\"by\":\"bob\"}\n\ \n\ garbage line\n"; let ev = parse_events(log); assert_eq!(ev.len(), 2); assert_eq!(ev[0].thing, "rust"); assert_eq!(ev[0].delta, 1); assert_eq!(ev[0].by.as_deref(), Some("alice")); assert_eq!(ev[0].why.as_deref(), Some("clean PR")); assert_eq!(ev[1].delta, -1); assert!(ev[1].why.is_none()); } #[test] fn renders_hero_podium_and_scores() { let b = parse_board( "libera", r#"{"rust":{"n":5,"why":"clean PR","by":"alice"}}"#, ); let html = render(&[b], 0, Locale::En); assert!(html.contains(">karma<"), "wordmark missing"); assert!(html.contains("podium"), "no podium"); assert!(html.contains(">rust<"), "champion nick missing"); assert!(html.contains(">+5<"), "score missing"); assert!(html.contains("clean PR"), "reason missing"); } #[test] fn renders_activity_and_givers_from_events() { let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"alice\",\"why\":\"clean PR\"}\n\ {\"t\":200,\"o\":\"docs\",\"d\":1,\"by\":\"alice\"}\n"; let b = board_with_events("libera", r#"{"rust":1,"docs":1}"#, log); let html = render(&[b], 0, Locale::En); assert!(html.contains("recent activity"), "no activity section"); assert!(html.contains("top givers"), "no givers section"); assert!(html.contains("gave"), "no feed verb"); assert!(html.contains("2 gifts"), "giver count wrong"); assert!(html.contains("data-t=\"200\""), "feed newest-first missing"); } #[test] fn empty_states_are_friendly() { let html = render(&[], 0, Locale::En); assert!(html.contains("No karma yet"), "{html}"); let b = parse_board("libera", r#"{"romaka":2}"#); let html = render(&[b], 0, Locale::En); assert!(html.contains("Nothing yet"), "empty activity state missing"); assert!(html.contains("Be the first"), "empty givers state missing"); } #[test] fn renders_spanish_chrome() { let log = "{\"t\":100,\"o\":\"rust\",\"d\":1,\"by\":\"nadia\",\"why\":\"buen PR\"}\n"; let b = board_with_events("libera", r#"{"rust":1}"#, log); let html = render(&[b], 0, Locale::Es); assert!(html.contains("karma neto"), "es stat label missing"); assert!( html.contains("actividad reciente"), "es activity heading missing" ); assert!(html.contains("top donantes"), "es givers heading missing"); assert!(html.contains("le dio a"), "es feed verb missing"); // relative-time config for the client is Spanish assert!(html.contains("\"pre\":\"hace \""), "es time config missing"); // user content is never translated assert!(html.contains("buen PR") && html.contains(">rust<")); } #[test] fn parses_meta_channels_with_and_without_topic() { // new format: objects with an optional topic let (server, channels) = parse_meta( r##"{"server":"irc.libera.chat","channels":[{"name":"#argentina","topic":"Bienvenidos"},{"name":"#ar"}]}"##, ); assert_eq!(server, "irc.libera.chat"); assert_eq!(channels.len(), 2); assert_eq!(channels[0].name, "#argentina"); assert_eq!(channels[0].topic.as_deref(), Some("Bienvenidos")); assert!(channels[1].topic.is_none()); // old format (bare strings) still parses let (_, old) = parse_meta(r##"{"server":"x","channels":["#c"]}"##); assert_eq!(old[0].name, "#c"); assert!(old[0].topic.is_none()); // garbage degrades to empty let (s, c) = parse_meta("not json"); assert!(s.is_empty() && c.is_empty()); } #[test] fn renders_source_channel_server_and_topic() { let mut b = parse_board("libera", r#"{"romaka":2}"#); b.server = "irc.libera.chat".to_string(); b.channels = vec![Channel { name: "#argentina".to_string(), topic: Some("Argentina en Libera - stats https://qadlo.example/".to_string()), }]; let en = render(&[b], 0, Locale::En); assert!(en.contains("#argentina"), "channel missing"); assert!(en.contains("irc.libera.chat"), "server missing"); assert!(en.contains(">from<"), "from label missing"); // the topic text shows, and its URL becomes a safe link assert!(en.contains("Argentina en Libera"), "topic text missing"); assert!( en.contains("desde<") && es.contains("#argentina"), "{es}"); } #[test] fn topic_cannot_inject_markup() { let mut b = parse_board("t", r#"{"x":1}"#); b.channels = vec![Channel { name: "#c".to_string(), topic: Some(" javascript:alert(1)".to_string()), }]; let html = render(&[b], 0, Locale::En); assert!(!html.contains("