261 lines
8.2 KiB
Rust
261 lines
8.2 KiB
Rust
use rustbot::bot::module::{Action, Chat, Command, Module};
|
|
use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma};
|
|
use rustbot::i18n::Locale;
|
|
|
|
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
|
|
Chat {
|
|
sender,
|
|
reply_to: "#c",
|
|
text,
|
|
prefix: ">",
|
|
network: "t",
|
|
}
|
|
}
|
|
|
|
fn visible(s: &str) -> String {
|
|
s.replace('\u{200B}', "")
|
|
}
|
|
|
|
fn reply_of(k: &mut Karma, name: &str, args: &[&str]) -> String {
|
|
let cmd = Command {
|
|
sender: "z",
|
|
reply_to: "#c",
|
|
name,
|
|
args,
|
|
prefix: ">",
|
|
network: "t",
|
|
};
|
|
match k.on_command(&cmd).as_slice() {
|
|
[Action::Reply(s)] => visible(s),
|
|
_ => panic!("expected one reply"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn parses_increments_and_decrements() {
|
|
assert_eq!(
|
|
parse_changes("foo++ bar-- baz", "someone"),
|
|
vec![("foo".to_string(), 1), ("bar".to_string(), -1)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strips_trailing_punctuation_and_parens() {
|
|
let c = parse_changes("nice work rust++, and (team)++!", "someone");
|
|
assert!(c.contains(&("rust".to_string(), 1)), "{c:?}");
|
|
assert!(c.contains(&("team".to_string(), 1)), "{c:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn no_self_karma_case_insensitive() {
|
|
assert!(parse_changes("me++", "me").is_empty());
|
|
assert!(parse_changes("Me++", "me").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dedupes_within_a_message() {
|
|
assert_eq!(
|
|
parse_changes("foo++ foo++ foo++", "x"),
|
|
vec![("foo".to_string(), 1)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_single_char_and_numeric_targets() {
|
|
assert_eq!(
|
|
parse_changes("i++ 5++ x-- ok++", "s"),
|
|
vec![("ok".to_string(), 1)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_reason_after_single_bump() {
|
|
assert_eq!(
|
|
extract_reason("rust++ for the clean PR").as_deref(),
|
|
Some("the clean PR")
|
|
);
|
|
assert_eq!(
|
|
extract_reason("rust++ because it's fast").as_deref(),
|
|
Some("it's fast")
|
|
);
|
|
assert_eq!(
|
|
extract_reason("rust++: solid docs").as_deref(),
|
|
Some("solid docs")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn extract_reason_none_when_ambiguous_or_absent() {
|
|
assert_eq!(extract_reason("great work rust++"), None);
|
|
assert_eq!(extract_reason("foo++ bar++ nice"), None);
|
|
assert_eq!(extract_reason("just chatting"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn bumps_persist_case_insensitively() {
|
|
let path = std::env::temp_dir().join("rubot-test-karma-a.json");
|
|
let _ = std::fs::remove_file(&path);
|
|
let mut k = Karma::with_path(path.clone());
|
|
k.on_message(&chat("alice", "Rust++ rust++ python--"));
|
|
let reloaded = Karma::with_path(path.clone());
|
|
assert_eq!(reloaded.get("rust"), 1);
|
|
assert_eq!(reloaded.get("RUST"), 1);
|
|
assert_eq!(reloaded.get("python"), -1);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn report_shows_rank_and_reason() {
|
|
let path = std::env::temp_dir().join("rubot-test-karma-rank.json");
|
|
let _ = std::fs::remove_file(&path);
|
|
let mut k = Karma::with_path(path.clone());
|
|
k.on_message(&chat("alice", "rust++ for the clean PR"));
|
|
k.on_message(&chat("carol", "rust++")); // no reason -> keeps alice's
|
|
k.on_message(&chat("bob", "beta++"));
|
|
let r = reply_of(&mut k, "karma", &["rust"]);
|
|
assert!(r.contains("rust has 2 karma (#1 of 2)"), "{r}");
|
|
assert!(r.contains("last: \"the clean PR\" (alice)"), "{r}");
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn unseen_thing_reports_no_karma() {
|
|
let path = std::env::temp_dir().join("rubot-test-karma-unseen.json");
|
|
let _ = std::fs::remove_file(&path);
|
|
let mut k = Karma::with_path(path.clone());
|
|
assert!(reply_of(&mut k, "karma", &["ghost"]).contains("no karma for 'ghost' yet"));
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn leaderboard_top_and_bottom() {
|
|
let path = std::env::temp_dir().join("rubot-test-karma-top.json");
|
|
let _ = std::fs::remove_file(&path);
|
|
let mut k = Karma::with_path(path.clone());
|
|
k.on_message(&chat("a", "high++ mid++ low--"));
|
|
k.on_message(&chat("b", "high++"));
|
|
let top = reply_of(&mut k, "karma", &[]);
|
|
assert!(
|
|
top.starts_with("karma top:") && top.contains("high (2)"),
|
|
"{top}"
|
|
);
|
|
let bottom = reply_of(&mut k, "karmabottom", &[]);
|
|
assert!(
|
|
bottom.starts_with("karma bottom:") && bottom.contains("low (-1)"),
|
|
"{bottom}"
|
|
);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
|
|
#[test]
|
|
fn milestone_announced_at_ten() {
|
|
let path = std::env::temp_dir().join("rubot-test-karma-ms.json");
|
|
let _ = std::fs::remove_file(&path);
|
|
let mut k = Karma::with_path(path.clone());
|
|
for i in 0..9 {
|
|
let s = format!("u{i}");
|
|
assert!(
|
|
k.on_message(&chat(&s, "rust++")).is_empty(),
|
|
"announce at {i}"
|
|
);
|
|
}
|
|
let a = k.on_message(&chat("u9", "rust++"));
|
|
let msg = match a.as_slice() {
|
|
[Action::Reply(s)] => visible(s),
|
|
_ => panic!("expected a milestone announce"),
|
|
};
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn spanish_reason_connector_is_stripped() {
|
|
assert_eq!(
|
|
extract_reason("rust++ porque es rápido").as_deref(),
|
|
Some("es rápido")
|
|
);
|
|
assert_eq!(
|
|
extract_reason("rust++ por los tests").as_deref(),
|
|
Some("los tests")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn replies_in_spanish_for_es_locale() {
|
|
let path = std::env::temp_dir().join("rubot-test-karma-es.json");
|
|
let log = std::env::temp_dir().join("rubot-test-karma-es.events.jsonl");
|
|
let _ = std::fs::remove_file(&path);
|
|
let _ = std::fs::remove_file(&log);
|
|
let mut k = Karma::with_path_locale(path.clone(), Locale::Es);
|
|
k.on_message(&chat("alice", "rust++ porque es bueno"));
|
|
// report
|
|
let cmd = Command {
|
|
sender: "z",
|
|
reply_to: "#argentina",
|
|
name: "karma",
|
|
args: &["rust"],
|
|
prefix: ">",
|
|
network: "libera",
|
|
};
|
|
let r = match k.on_command(&cmd).as_slice() {
|
|
[Action::Reply(s)] => visible(s),
|
|
_ => panic!("expected one reply"),
|
|
};
|
|
assert!(r.contains("tiene 1 de karma"), "{r}");
|
|
assert!(r.contains("(#1 de 1)"), "{r}");
|
|
assert!(r.contains("último: \"es bueno\""), "{r}");
|
|
// unseen thing
|
|
let cmd2 = Command {
|
|
args: &["ghost"],
|
|
..cmd
|
|
};
|
|
let ghost = match k.on_command(&cmd2).as_slice() {
|
|
[Action::Reply(s)] => visible(s),
|
|
_ => panic!("expected one reply"),
|
|
};
|
|
assert!(ghost.contains("todavía no hay karma para"), "{ghost}");
|
|
let _ = std::fs::remove_file(&path);
|
|
let _ = std::fs::remove_file(&log);
|
|
}
|