karma: reasons, milestones, no-highlight replies
All checks were successful
ci / check (push) Successful in 47s
All checks were successful
ci / check (push) Successful in 47s
This commit is contained in:
parent
af9d5eed25
commit
e801b04d6e
3 changed files with 222 additions and 87 deletions
|
|
@ -68,7 +68,7 @@ lists them at runtime and `help <command>` describes one.
|
||||||
|---|---|
|
|---|---|
|
||||||
| `ping` / `echo <text>` / `hello` | basics |
|
| `ping` / `echo <text>` / `hello` | basics |
|
||||||
| `roll [NdM]` | roll dice (default `1d6`) |
|
| `roll [NdM]` | roll dice (default `1d6`) |
|
||||||
| `karma [thing]` / `karmabottom` (bump with `thing++` / `thing--`) | karma + rank, or the top/bottom list |
|
| `karma [thing]` / `karmabottom` (bump with `thing++ [reason]` / `thing--`) | karma + rank + last reason, or top/bottom; milestones announced |
|
||||||
| `md5` / `sha1` / `sha256` / `hash <text>` | hex digests |
|
| `md5` / `sha1` / `sha256` / `hash <text>` | hex digests |
|
||||||
| `add <location>` then `w [location]` | weather (wttr.in), per-user location |
|
| `add <location>` then `w [location]` | weather (wttr.in), per-user location |
|
||||||
| `crypto <sym>` | coin price in USD (Bitstamp) |
|
| `crypto <sym>` | coin price in USD (Bitstamp) |
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,10 @@ const COMMANDS: &[CommandSpec] = &[
|
||||||
];
|
];
|
||||||
|
|
||||||
const MAX_THING_LEN: usize = 32;
|
const MAX_THING_LEN: usize = 32;
|
||||||
|
const MAX_REASON_LEN: usize = 120;
|
||||||
const TOP_N: usize = 5;
|
const TOP_N: usize = 5;
|
||||||
|
const MILESTONES: &[i64] = &[10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
||||||
|
const ZWSP: char = '\u{200B}';
|
||||||
|
|
||||||
pub struct Karma {
|
pub struct Karma {
|
||||||
store: Store,
|
store: Store,
|
||||||
|
|
@ -42,18 +45,26 @@ impl Karma {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get(&self, thing: &str) -> i64 {
|
pub fn get(&self, thing: &str) -> i64 {
|
||||||
self.store.get(thing)
|
self.store.entry(thing).map(|e| e.count).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn report(&self, thing: &str) -> String {
|
fn report(&self, thing: &str) -> String {
|
||||||
if !self.store.has(thing) {
|
let Some(e) = self.store.entry(thing) else {
|
||||||
return format!("no karma for '{thing}' yet");
|
return format!("no karma for '{}' yet", dehighlight(thing));
|
||||||
}
|
};
|
||||||
let (rank, total) = self.store.rank(thing);
|
let (rank, total) = self.store.rank(thing);
|
||||||
format!(
|
let mut out = format!(
|
||||||
"{thing} has {} karma (#{rank} of {total})",
|
"{} has {} karma (#{rank} of {total})",
|
||||||
self.store.get(thing)
|
dehighlight(thing),
|
||||||
)
|
e.count
|
||||||
|
);
|
||||||
|
if let Some(reason) = &e.reason {
|
||||||
|
match &e.by {
|
||||||
|
Some(by) => out.push_str(&format!(" — last: \"{reason}\" ({})", dehighlight(by))),
|
||||||
|
None => out.push_str(&format!(" — last: \"{reason}\"")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
fn leaderboard(&self, bottom: bool) -> String {
|
fn leaderboard(&self, bottom: bool) -> String {
|
||||||
|
|
@ -61,7 +72,10 @@ impl Karma {
|
||||||
if entries.is_empty() {
|
if entries.is_empty() {
|
||||||
return "no karma yet".to_string();
|
return "no karma yet".to_string();
|
||||||
}
|
}
|
||||||
let items: Vec<String> = entries.iter().map(|(k, v)| format!("{k} ({v})")).collect();
|
let items: Vec<String> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| format!("{} ({v})", dehighlight(k)))
|
||||||
|
.collect();
|
||||||
format!(
|
format!(
|
||||||
"karma {}: {}",
|
"karma {}: {}",
|
||||||
if bottom { "bottom" } else { "top" },
|
if bottom { "bottom" } else { "top" },
|
||||||
|
|
@ -96,8 +110,22 @@ impl Module for Karma {
|
||||||
if changes.is_empty() {
|
if changes.is_empty() {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
for (thing, delta) in changes {
|
// A reason is only attributed when the message has a single bump.
|
||||||
self.store.bump(&thing, delta);
|
let reason = extract_reason(chat.text);
|
||||||
|
let mut announces = Vec::new();
|
||||||
|
for (thing, delta) in &changes {
|
||||||
|
let r = if changes.len() == 1 {
|
||||||
|
reason.as_deref()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let new = self.store.bump(thing, *delta, r, chat.sender);
|
||||||
|
if *delta > 0 && MILESTONES.contains(&new) {
|
||||||
|
announces.push(Action::reply(format!(
|
||||||
|
"🎉 {} reached {new} karma!",
|
||||||
|
dehighlight(thing)
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Err(e) = self.store.save() {
|
if let Err(e) = self.store.save() {
|
||||||
crate::log(&format!(
|
crate::log(&format!(
|
||||||
|
|
@ -105,7 +133,7 @@ impl Module for Karma {
|
||||||
self.store.path.display()
|
self.store.path.display()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Vec::new()
|
announces
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,13 +164,62 @@ pub fn parse_changes(text: &str, sender: &str) -> Vec<(String, i64)> {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The reason trailing a bump, but only when the message has exactly one bump
|
||||||
|
/// (otherwise it's ambiguous which thing the reason belongs to).
|
||||||
|
pub fn extract_reason(text: &str) -> Option<String> {
|
||||||
|
let toks: Vec<&str> = text.split_whitespace().collect();
|
||||||
|
let bumps: Vec<usize> = toks
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, t)| {
|
||||||
|
let t = t.trim_end_matches([',', '.', '!', '?', ';', ':']);
|
||||||
|
t.ends_with("++") || t.ends_with("--")
|
||||||
|
})
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
if bumps.len() != 1 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut reason = toks[bumps[0] + 1..].join(" ");
|
||||||
|
reason = reason.trim().to_string();
|
||||||
|
for prefix in ["for ", "because ", "cause ", "b/c "] {
|
||||||
|
if let Some(r) = reason.strip_prefix(prefix) {
|
||||||
|
reason = r.to_string();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reason = reason.trim_start_matches(['#', ':', '-', ' ']).trim();
|
||||||
|
if reason.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(reason.chars().take(MAX_REASON_LEN).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_valid_target(thing: &str) -> bool {
|
fn is_valid_target(thing: &str) -> bool {
|
||||||
let len = thing.chars().count();
|
let len = thing.chars().count();
|
||||||
(2..=MAX_THING_LEN).contains(&len) && !thing.chars().all(|c| c.is_ascii_digit())
|
(2..=MAX_THING_LEN).contains(&len) && !thing.chars().all(|c| c.is_ascii_digit())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Insert a zero-width space after the first character so echoing a name into
|
||||||
|
/// the channel doesn't ping that nick.
|
||||||
|
fn dehighlight(name: &str) -> String {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => format!("{first}{ZWSP}{}", chars.as_str()),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct Entry {
|
||||||
|
count: i64,
|
||||||
|
reason: Option<String>,
|
||||||
|
by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
struct Store {
|
struct Store {
|
||||||
map: HashMap<String, i64>,
|
map: HashMap<String, Entry>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,9 +232,7 @@ impl Store {
|
||||||
let mut m = HashMap::new();
|
let mut m = HashMap::new();
|
||||||
if let Json::Obj(entries) = j {
|
if let Json::Obj(entries) = j {
|
||||||
for (k, v) in entries {
|
for (k, v) in entries {
|
||||||
if let Some(n) = v.as_f64() {
|
m.insert(k, entry_from_json(&v));
|
||||||
m.insert(k, n as i64);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
m
|
m
|
||||||
|
|
@ -170,7 +245,15 @@ impl Store {
|
||||||
let items: Vec<String> = self
|
let items: Vec<String> = self
|
||||||
.map
|
.map
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| format!("\"{}\":{}", json::escape(k), v))
|
.map(|(k, e)| {
|
||||||
|
format!(
|
||||||
|
"\"{}\":{{\"n\":{},\"why\":\"{}\",\"by\":\"{}\"}}",
|
||||||
|
json::escape(k),
|
||||||
|
e.count,
|
||||||
|
json::escape(e.reason.as_deref().unwrap_or("")),
|
||||||
|
json::escape(e.by.as_deref().unwrap_or(""))
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let body = format!("{{{}}}\n", items.join(","));
|
let body = format!("{{{}}}\n", items.join(","));
|
||||||
let mut tmp = self.path.clone().into_os_string();
|
let mut tmp = self.path.clone().into_os_string();
|
||||||
|
|
@ -180,20 +263,23 @@ impl Store {
|
||||||
fs::rename(&tmp, &self.path)
|
fs::rename(&tmp, &self.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bump(&mut self, thing: &str, delta: i64) {
|
fn bump(&mut self, thing: &str, delta: i64, reason: Option<&str>, by: &str) -> i64 {
|
||||||
*self.map.entry(thing.to_lowercase()).or_insert(0) += delta;
|
let e = self.map.entry(thing.to_lowercase()).or_default();
|
||||||
|
e.count += delta;
|
||||||
|
if let Some(r) = reason {
|
||||||
|
e.reason = Some(r.to_string());
|
||||||
|
e.by = Some(by.to_string());
|
||||||
|
}
|
||||||
|
e.count
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get(&self, thing: &str) -> i64 {
|
fn entry(&self, thing: &str) -> Option<&Entry> {
|
||||||
*self.map.get(&thing.to_lowercase()).unwrap_or(&0)
|
self.map.get(&thing.to_lowercase())
|
||||||
}
|
|
||||||
|
|
||||||
fn has(&self, thing: &str) -> bool {
|
|
||||||
self.map.contains_key(&thing.to_lowercase())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ranked(&self, n: usize, bottom: bool) -> Vec<(String, i64)> {
|
fn ranked(&self, n: usize, bottom: bool) -> Vec<(String, i64)> {
|
||||||
let mut v: Vec<(String, i64)> = self.map.iter().map(|(k, val)| (k.clone(), *val)).collect();
|
let mut v: Vec<(String, i64)> =
|
||||||
|
self.map.iter().map(|(k, e)| (k.clone(), e.count)).collect();
|
||||||
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||||
if bottom {
|
if bottom {
|
||||||
v.reverse();
|
v.reverse();
|
||||||
|
|
@ -204,8 +290,8 @@ impl Store {
|
||||||
|
|
||||||
fn rank(&self, thing: &str) -> (usize, usize) {
|
fn rank(&self, thing: &str) -> (usize, usize) {
|
||||||
let key = thing.to_lowercase();
|
let key = thing.to_lowercase();
|
||||||
let mut v: Vec<(&String, &i64)> = self.map.iter().collect();
|
let mut v: Vec<(&String, i64)> = self.map.iter().map(|(k, e)| (k, e.count)).collect();
|
||||||
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
|
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
|
||||||
let rank = v
|
let rank = v
|
||||||
.iter()
|
.iter()
|
||||||
.position(|(k, _)| **k == key)
|
.position(|(k, _)| **k == key)
|
||||||
|
|
@ -214,3 +300,26 @@ impl Store {
|
||||||
(rank, v.len())
|
(rank, v.len())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn entry_from_json(v: &Json) -> Entry {
|
||||||
|
// Backwards compatible: old files stored a bare number per key.
|
||||||
|
if let Some(n) = v.as_f64() {
|
||||||
|
return Entry {
|
||||||
|
count: n as i64,
|
||||||
|
..Entry::default()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Entry {
|
||||||
|
count: v.get("n").and_then(Json::as_f64).unwrap_or(0.0) as i64,
|
||||||
|
reason: v
|
||||||
|
.get("why")
|
||||||
|
.and_then(Json::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
by: v
|
||||||
|
.get("by")
|
||||||
|
.and_then(Json::as_str)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
142
tests/karma.rs
142
tests/karma.rs
|
|
@ -1,5 +1,5 @@
|
||||||
use rustbot::bot::module::{Action, Chat, Command, Module};
|
use rustbot::bot::module::{Action, Chat, Command, Module};
|
||||||
use rustbot::bot::modules::karma::{parse_changes, Karma};
|
use rustbot::bot::modules::karma::{extract_reason, parse_changes, Karma};
|
||||||
|
|
||||||
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
|
fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
|
||||||
Chat {
|
Chat {
|
||||||
|
|
@ -11,6 +11,25 @@ fn chat<'a>(sender: &'a str, text: &'a str) -> Chat<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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]
|
#[test]
|
||||||
fn parses_increments_and_decrements() {
|
fn parses_increments_and_decrements() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -40,57 +59,6 @@ fn dedupes_within_a_message() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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); // deduped to one bump
|
|
||||||
assert_eq!(reloaded.get("RUST"), 1);
|
|
||||||
assert_eq!(reloaded.get("python"), -1);
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn command_reports_karma() {
|
|
||||||
let path = std::env::temp_dir().join("rubot-test-karma-b.json");
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let mut k = Karma::with_path(path.clone());
|
|
||||||
k.on_message(&chat("a", "foo++ foo++"));
|
|
||||||
let cmd = Command {
|
|
||||||
sender: "a",
|
|
||||||
reply_to: "#c",
|
|
||||||
name: "karma",
|
|
||||||
args: &["foo"],
|
|
||||||
prefix: ">",
|
|
||||||
network: "t",
|
|
||||||
};
|
|
||||||
let out = match k.on_command(&cmd).as_slice() {
|
|
||||||
[Action::Reply(s)] => s.clone(),
|
|
||||||
_ => panic!("expected one reply"),
|
|
||||||
};
|
|
||||||
assert!(out.contains("foo has 1 karma"), "{out}");
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
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)] => s.clone(),
|
|
||||||
_ => panic!("expected one reply"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ignores_single_char_and_numeric_targets() {
|
fn ignores_single_char_and_numeric_targets() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -100,15 +68,52 @@ fn ignores_single_char_and_numeric_targets() {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn report_shows_rank() {
|
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 path = std::env::temp_dir().join("rubot-test-karma-rank.json");
|
||||||
let _ = std::fs::remove_file(&path);
|
let _ = std::fs::remove_file(&path);
|
||||||
let mut k = Karma::with_path(path.clone());
|
let mut k = Karma::with_path(path.clone());
|
||||||
k.on_message(&chat("a", "alpha++"));
|
k.on_message(&chat("alice", "rust++ for the clean PR"));
|
||||||
k.on_message(&chat("b", "alpha++"));
|
k.on_message(&chat("carol", "rust++")); // no reason -> keeps alice's
|
||||||
k.on_message(&chat("c", "alpha++ beta++"));
|
k.on_message(&chat("bob", "beta++"));
|
||||||
assert!(reply_of(&mut k, "karma", &["alpha"]).contains("alpha has 3 karma (#1 of 2)"));
|
let r = reply_of(&mut k, "karma", &["rust"]);
|
||||||
assert!(reply_of(&mut k, "karma", &["beta"]).contains("beta has 1 karma (#2 of 2)"));
|
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);
|
let _ = std::fs::remove_file(&path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -140,3 +145,24 @@ fn leaderboard_top_and_bottom() {
|
||||||
);
|
);
|
||||||
let _ = std::fs::remove_file(&path);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue