use rustbot::bot::module::{Action, Chat, Command, Module}; use rustbot::bot::modules::karma::{extract_reason, format_event, parse_changes, Karma}; 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); }