karma: log bump events; rebuild web page with podium, sparklines, activity feed and givers
All checks were successful
ci / check (push) Successful in 44s
All checks were successful
ci / check (push) Successful in 44s
This commit is contained in:
parent
9a3393153a
commit
f290554325
7 changed files with 710 additions and 141 deletions
|
|
@ -1,5 +1,5 @@
|
|||
use rustbot::bot::module::{Action, Chat, Command, Module};
|
||||
use rustbot::bot::modules::karma::{extract_reason, parse_changes, Karma};
|
||||
use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma};
|
||||
|
||||
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
|
||||
Chat {
|
||||
|
|
@ -166,3 +166,45 @@ fn milestone_announced_at_ten() {
|
|||
assert!(msg.contains("rust reached 10 karma"), "{msg}");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_event_is_one_json_line() {
|
||||
let line = format_event(1700000000, "rust", 1, "alice", Some("clean PR"));
|
||||
assert!(line.ends_with('\n'));
|
||||
assert_eq!(line.matches('\n').count(), 1);
|
||||
assert!(line.contains("\"t\":1700000000"), "{line}");
|
||||
assert!(line.contains("\"o\":\"rust\""), "{line}");
|
||||
assert!(line.contains("\"d\":1"), "{line}");
|
||||
assert!(line.contains("\"by\":\"alice\""), "{line}");
|
||||
assert!(line.contains("\"why\":\"clean PR\""), "{line}");
|
||||
// no reason -> no why field
|
||||
let bare = format_event(1, "docs", -1, "bob", None);
|
||||
assert!(!bare.contains("why"), "{bare}");
|
||||
// quotes in a reason are escaped
|
||||
let q = format_event(1, "x", 1, "y", Some("he said \"hi\""));
|
||||
assert!(q.contains("\\\"hi\\\""), "{q}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_message_appends_to_the_event_log() {
|
||||
let path = std::env::temp_dir().join("rubot-test-karma-ev.json");
|
||||
let log = std::env::temp_dir().join("rubot-test-karma-ev.events.jsonl");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
let mut k = Karma::with_path(path.clone());
|
||||
k.on_message(&chat("alice", "rust++ for the clean PR"));
|
||||
k.on_message(&chat("bob", "rust-- python++"));
|
||||
let body = std::fs::read_to_string(&log).expect("event log written");
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "one line per bump: {body}");
|
||||
assert!(lines[0].contains("\"o\":\"rust\"") && lines[0].contains("\"by\":\"alice\""));
|
||||
assert!(
|
||||
lines[0].contains("\"why\":\"the clean PR\""),
|
||||
"{}",
|
||||
lines[0]
|
||||
);
|
||||
// the two-bump message attributes no reason
|
||||
assert!(!lines[1].contains("why") && !lines[2].contains("why"));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(&log);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
use rustbot::karma_web::{parse_board, render};
|
||||
use rustbot::karma_web::{parse_board, parse_events, render};
|
||||
|
||||
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() {
|
||||
|
|
@ -6,7 +12,6 @@ fn parses_flat_number_format() {
|
|||
assert_eq!(b.rows.len(), 1);
|
||||
assert_eq!(b.rows[0].thing, "romaka");
|
||||
assert_eq!(b.rows[0].count, 2);
|
||||
assert!(b.rows[0].reason.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -25,36 +30,81 @@ fn sorts_descending_by_count() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn renders_scores_and_reason() {
|
||||
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);
|
||||
assert!(html.contains("libera"), "{html}");
|
||||
assert!(html.contains(">rust<"), "{html}");
|
||||
assert!(html.contains(">+5<"), "{html}");
|
||||
assert!(html.contains("clean PR"), "{html}");
|
||||
assert!(html.contains("alice"), "{html}");
|
||||
assert!(
|
||||
html.contains(">karma<"),
|
||||
"wordmark missing: has no karma mark"
|
||||
);
|
||||
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 escapes_untrusted_irc_input() {
|
||||
let b = parse_board(
|
||||
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);
|
||||
assert!(html.contains("recent activity"), "no activity section");
|
||||
assert!(html.contains("top givers"), "no givers section");
|
||||
assert!(html.contains("gave"), "no feed verb");
|
||||
// alice made two gifts -> shown in givers panel
|
||||
assert!(
|
||||
html.contains("2 gifts"),
|
||||
"giver count wrong: {}",
|
||||
html.contains("gift")
|
||||
);
|
||||
assert!(
|
||||
html.contains("data-t=\"200\""),
|
||||
"feed sorted/newest-first missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_states_are_friendly() {
|
||||
// no karma at all
|
||||
let html = render(&[], 0);
|
||||
assert!(html.contains("No karma yet"), "{html}");
|
||||
// karma but no event history yet
|
||||
let b = parse_board("libera", r#"{"romaka":2}"#);
|
||||
let html = render(&[b], 0);
|
||||
assert!(html.contains("Nothing yet"), "empty activity state missing");
|
||||
assert!(html.contains("Be the first"), "empty givers state missing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escapes_untrusted_irc_input_everywhere() {
|
||||
let log = "{\"t\":1,\"o\":\"<script>\",\"d\":1,\"by\":\"a&b\",\"why\":\"\\\"><img src=x>\"}\n";
|
||||
let b = board_with_events(
|
||||
"t",
|
||||
r#"{"<script>":{"n":1,"why":"\"><img src=x>","by":"a&b"}}"#,
|
||||
log,
|
||||
);
|
||||
let html = render(&[b], 0);
|
||||
// the page legitimately has its own <script> block; what matters is that the
|
||||
// untrusted nick/reason are escaped and never emit a live tag of their own.
|
||||
assert!(!html.contains("<img"), "raw img tag leaked: {html}");
|
||||
assert!(html.contains("<script>"), "{html}");
|
||||
assert!(html.contains("a&b"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_shows_instructions() {
|
||||
let html = render(&[], 0);
|
||||
assert!(html.contains("no karma yet"), "{html}");
|
||||
assert!(html.contains("nick++"), "{html}");
|
||||
assert!(!html.contains("<img"), "raw img tag leaked");
|
||||
assert!(html.contains("<script>"), "nick not escaped");
|
||||
assert!(html.contains("a&b"), "giver not escaped");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue