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
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@ rustbot.service
|
|||
rustbot.conf
|
||||
weather.*.json
|
||||
karma.*.json
|
||||
karma.*.events.jsonl
|
||||
*.json.tmp
|
||||
karma-web.service
|
||||
karma-web.timer
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -79,10 +79,13 @@ lists them at runtime and `help <command>` describes one.
|
|||
|
||||
## Karma web page
|
||||
|
||||
The live karma board is published at **<https://karma.devtronic.pro>**. A second
|
||||
binary, `karma-web`, reads the per-network `karma.*.json` files and renders one
|
||||
self-contained HTML page (no JS/CSS/font dependencies, all IRC-supplied text
|
||||
HTML-escaped); a systemd timer regenerates it every minute and nginx serves it
|
||||
The live karma board is published at **<https://karma.devtronic.pro>** — a hero
|
||||
with live stats, a top-3 podium, a ranked ledger with per-thing trend
|
||||
sparklines, a recent-activity feed, and a top-givers board. A second binary,
|
||||
`karma-web`, reads the per-network `karma.*.json` totals plus the append-only
|
||||
`karma.*.events.jsonl` history the bot writes on every bump, and renders one
|
||||
self-contained HTML page (no CSS/JS/font dependencies, all IRC-supplied text
|
||||
HTML-escaped). A systemd timer regenerates it every minute and nginx serves it
|
||||
over TLS.
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::fs;
|
|||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use rustbot::karma_web::{parse_board, render, Board};
|
||||
use rustbot::karma_web::{parse_board, parse_events, render, Board};
|
||||
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1);
|
||||
|
|
@ -23,7 +23,12 @@ fn main() {
|
|||
.and_then(|s| s.strip_suffix(".json"))
|
||||
{
|
||||
if let Ok(body) = fs::read_to_string(&p) {
|
||||
boards.push(parse_board(net, &body));
|
||||
let mut board = parse_board(net, &body);
|
||||
let ev = p.with_file_name(format!("karma.{net}.events.jsonl"));
|
||||
if let Ok(evbody) = fs::read_to_string(&ev) {
|
||||
board.events = parse_events(&evbody);
|
||||
}
|
||||
boards.push(board);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::bot::module::{Action, Chat, Command, CommandSpec, Module};
|
||||
use crate::json::{self, Json};
|
||||
|
|
@ -112,6 +115,7 @@ impl Module for Karma {
|
|||
}
|
||||
// A reason is only attributed when the message has a single bump.
|
||||
let reason = extract_reason(chat.text);
|
||||
let t = now();
|
||||
let mut announces = Vec::new();
|
||||
for (thing, delta) in &changes {
|
||||
let r = if changes.len() == 1 {
|
||||
|
|
@ -119,6 +123,8 @@ impl Module for Karma {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
self.store
|
||||
.log_event(&format_event(t, thing, *delta, chat.sender, r));
|
||||
let new = self.store.bump(thing, *delta, r, chat.sender);
|
||||
if *delta > 0 && MILESTONES.contains(&new) {
|
||||
announces.push(Action::reply(format!(
|
||||
|
|
@ -221,6 +227,7 @@ struct Entry {
|
|||
struct Store {
|
||||
map: HashMap<String, Entry>,
|
||||
path: PathBuf,
|
||||
events_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
|
|
@ -238,7 +245,22 @@ impl Store {
|
|||
m
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Store { map, path }
|
||||
let events_path = events_path_for(&path);
|
||||
Store {
|
||||
map,
|
||||
path,
|
||||
events_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_event(&self, line: &str) {
|
||||
if let Ok(mut f) = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.events_path)
|
||||
{
|
||||
let _ = f.write_all(line.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn save(&self) -> std::io::Result<()> {
|
||||
|
|
@ -323,3 +345,28 @@ fn entry_from_json(v: &Json) -> Entry {
|
|||
.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
fn events_path_for(path: &Path) -> PathBuf {
|
||||
let p = path.to_string_lossy();
|
||||
let base = p.strip_suffix(".json").unwrap_or(&p);
|
||||
PathBuf::from(format!("{base}.events.jsonl"))
|
||||
}
|
||||
|
||||
fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn format_event(t: u64, thing: &str, delta: i64, by: &str, why: Option<&str>) -> String {
|
||||
let why_field = match why {
|
||||
Some(w) if !w.is_empty() => format!(",\"why\":\"{}\"", json::escape(w)),
|
||||
_ => String::new(),
|
||||
};
|
||||
format!(
|
||||
"{{\"t\":{t},\"o\":\"{}\",\"d\":{delta},\"by\":\"{}\"{why_field}}}\n",
|
||||
json::escape(thing),
|
||||
json::escape(by)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
641
src/karma_web.rs
641
src/karma_web.rs
|
|
@ -1,3 +1,5 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::json::Json;
|
||||
|
||||
pub struct Row {
|
||||
|
|
@ -7,9 +9,18 @@ pub struct Row {
|
|||
pub by: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Event {
|
||||
pub t: u64,
|
||||
pub thing: String,
|
||||
pub delta: i64,
|
||||
pub by: Option<String>,
|
||||
pub why: Option<String>,
|
||||
}
|
||||
|
||||
pub struct Board {
|
||||
pub network: String,
|
||||
pub rows: Vec<Row>,
|
||||
pub events: Vec<Event>,
|
||||
}
|
||||
|
||||
pub fn parse_board(network: &str, body: &str) -> Board {
|
||||
|
|
@ -33,9 +44,41 @@ pub fn parse_board(network: &str, body: &str) -> Board {
|
|||
Board {
|
||||
network: network.to_string(),
|
||||
rows,
|
||||
events: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_events(body: &str) -> Vec<Event> {
|
||||
let mut out = Vec::new();
|
||||
for line in body.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(j) = Json::parse(line) else { continue };
|
||||
let thing = match j.get("o").and_then(Json::as_str) {
|
||||
Some(s) if !s.is_empty() => s.to_string(),
|
||||
_ => continue,
|
||||
};
|
||||
out.push(Event {
|
||||
t: j.get("t").and_then(Json::as_f64).unwrap_or(0.0) as u64,
|
||||
thing,
|
||||
delta: j.get("d").and_then(Json::as_f64).unwrap_or(0.0) as i64,
|
||||
by: j
|
||||
.get("by")
|
||||
.and_then(Json::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
why: j
|
||||
.get("why")
|
||||
.and_then(Json::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string),
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fields(v: &Json) -> (i64, Option<String>, Option<String>) {
|
||||
if let Some(n) = v.as_f64() {
|
||||
return (n as i64, None, None);
|
||||
|
|
@ -69,70 +112,364 @@ fn esc(s: &str) -> String {
|
|||
o
|
||||
}
|
||||
|
||||
pub fn render(boards: &[Board], updated_epoch: u64) -> String {
|
||||
let total: usize = boards.iter().map(|b| b.rows.len()).sum();
|
||||
let mut s = String::from(HEAD);
|
||||
if total == 0 {
|
||||
s.push_str(EMPTY);
|
||||
fn hue(s: &str) -> u32 {
|
||||
let mut h: u32 = 2166136261;
|
||||
for b in s.to_lowercase().bytes() {
|
||||
h ^= b as u32;
|
||||
h = h.wrapping_mul(16777619);
|
||||
}
|
||||
h % 360
|
||||
}
|
||||
|
||||
fn initials(s: &str) -> String {
|
||||
let up: String = s
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric())
|
||||
.take(2)
|
||||
.collect::<String>()
|
||||
.to_uppercase();
|
||||
if up.is_empty() {
|
||||
"?".to_string()
|
||||
} else {
|
||||
s.push_str(SEARCH);
|
||||
for b in boards {
|
||||
if b.rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"<section class=\"netsec\"><div class=\"net\"><h2>{}</h2>\
|
||||
<span class=\"count\">{} {}</span></div><ol class=\"board\">",
|
||||
esc(&b.network),
|
||||
b.rows.len(),
|
||||
if b.rows.len() == 1 {
|
||||
"entry"
|
||||
} else {
|
||||
"entries"
|
||||
}
|
||||
));
|
||||
for (i, r) in b.rows.iter().enumerate() {
|
||||
s.push_str(&row(i + 1, r));
|
||||
}
|
||||
s.push_str("</ol></section>");
|
||||
up
|
||||
}
|
||||
}
|
||||
|
||||
fn avatar(name: &str, size: &str) -> String {
|
||||
format!(
|
||||
"<span class=\"ava {size}\" style=\"--h:{}\" aria-hidden=\"true\">{}</span>",
|
||||
hue(name),
|
||||
esc(&initials(name))
|
||||
)
|
||||
}
|
||||
|
||||
fn signed(n: i64) -> String {
|
||||
if n > 0 {
|
||||
format!("+{n}")
|
||||
} else {
|
||||
n.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn klass(n: i64) -> &'static str {
|
||||
match n {
|
||||
n if n > 0 => "up",
|
||||
n if n < 0 => "down",
|
||||
_ => "zero",
|
||||
}
|
||||
}
|
||||
|
||||
/// Cumulative karma over the logged events for `thing`, anchored so the last
|
||||
/// point equals the current stored count. Empty if there is nothing to plot.
|
||||
fn series_for(thing: &str, count: i64, events: &[Event]) -> Vec<i64> {
|
||||
let key = thing.to_lowercase();
|
||||
let deltas: Vec<i64> = events
|
||||
.iter()
|
||||
.filter(|e| e.thing.to_lowercase() == key)
|
||||
.map(|e| e.delta)
|
||||
.collect();
|
||||
if deltas.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let sum: i64 = deltas.iter().sum();
|
||||
let mut v = count - sum;
|
||||
let mut series = Vec::with_capacity(deltas.len() + 1);
|
||||
series.push(v);
|
||||
for d in deltas {
|
||||
v += d;
|
||||
series.push(v);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
fn sparkline(series: &[i64]) -> String {
|
||||
if series.len() < 2 {
|
||||
return String::new();
|
||||
}
|
||||
let (w, h, pad) = (104.0_f64, 30.0_f64, 4.0_f64);
|
||||
let min = *series.iter().min().unwrap() as f64;
|
||||
let max = *series.iter().max().unwrap() as f64;
|
||||
let span = if (max - min).abs() < f64::EPSILON {
|
||||
1.0
|
||||
} else {
|
||||
max - min
|
||||
};
|
||||
let n = series.len() as f64;
|
||||
let at = |i: usize, v: i64| -> (f64, f64) {
|
||||
let x = pad + (w - 2.0 * pad) * (i as f64) / (n - 1.0);
|
||||
let y = pad + (h - 2.0 * pad) * (1.0 - (v as f64 - min) / span);
|
||||
(x, y)
|
||||
};
|
||||
let mut pts = String::new();
|
||||
for (i, v) in series.iter().enumerate() {
|
||||
if i > 0 {
|
||||
pts.push(' ');
|
||||
}
|
||||
let (x, y) = at(i, *v);
|
||||
pts.push_str(&format!("{x:.1},{y:.1}"));
|
||||
}
|
||||
let (lx, ly) = at(series.len() - 1, *series.last().unwrap());
|
||||
let cls = if series.last() >= series.first() {
|
||||
"up"
|
||||
} else {
|
||||
"down"
|
||||
};
|
||||
format!(
|
||||
"<svg class=\"spark {cls}\" viewBox=\"0 0 {w:.0} {h:.0}\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\
|
||||
<polyline points=\"{pts}\"/><circle cx=\"{lx:.1}\" cy=\"{ly:.1}\" r=\"2.2\"/></svg>"
|
||||
)
|
||||
}
|
||||
|
||||
struct Giver {
|
||||
name: String,
|
||||
gives: i64,
|
||||
points: i64,
|
||||
}
|
||||
|
||||
fn top_givers(all: &[(&str, &Event)], n: usize) -> Vec<Giver> {
|
||||
let mut m: HashMap<String, (String, i64, i64)> = HashMap::new();
|
||||
for (_, e) in all {
|
||||
if let Some(by) = &e.by {
|
||||
let ent = m.entry(by.to_lowercase()).or_insert((by.clone(), 0, 0));
|
||||
ent.1 += 1;
|
||||
ent.2 += e.delta;
|
||||
}
|
||||
}
|
||||
s.push_str(&SCRIPT.replace("__EPOCH__", &updated_epoch.to_string()));
|
||||
let mut v: Vec<Giver> = m
|
||||
.into_values()
|
||||
.map(|(name, gives, points)| Giver {
|
||||
name,
|
||||
gives,
|
||||
points,
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(|a, b| {
|
||||
b.gives
|
||||
.cmp(&a.gives)
|
||||
.then(b.points.cmp(&a.points))
|
||||
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
v.truncate(n);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn render(boards: &[Board], updated_epoch: u64) -> String {
|
||||
let things: usize = boards.iter().map(|b| b.rows.len()).sum();
|
||||
let total_points: i64 = boards.iter().flat_map(|b| &b.rows).map(|r| r.count).sum();
|
||||
|
||||
let mut feed: Vec<(&str, &Event)> = boards
|
||||
.iter()
|
||||
.flat_map(|b| b.events.iter().map(move |e| (b.network.as_str(), e)))
|
||||
.collect();
|
||||
feed.sort_by_key(|(_, e)| std::cmp::Reverse(e.t));
|
||||
let givers_set: HashSet<String> = feed
|
||||
.iter()
|
||||
.filter_map(|(_, e)| e.by.as_ref().map(|s| s.to_lowercase()))
|
||||
.collect();
|
||||
let gifts = feed.len();
|
||||
|
||||
let mut s = String::from(HEAD);
|
||||
let multi = boards.iter().filter(|b| !b.rows.is_empty()).count() > 1;
|
||||
|
||||
// hero
|
||||
s.push_str("<header class=\"hero fade\"><div class=\"prompt\">rubot@devtronic</div>");
|
||||
s.push_str("<h1 class=\"mark\">karma<span class=\"cursor\">_</span></h1>");
|
||||
s.push_str(
|
||||
"<p class=\"tag\">reputation earned one <code>nick++</code> at a time, across the channels.</p>",
|
||||
);
|
||||
s.push_str("<div class=\"stats\">");
|
||||
s.push_str(&stat(&things.to_string(), "tracked"));
|
||||
s.push_str(&stat(&signed(total_points), "net karma"));
|
||||
s.push_str(&stat(&gifts.to_string(), "gifts logged"));
|
||||
s.push_str(&stat(&givers_set.len().to_string(), "givers"));
|
||||
s.push_str("</div></header>");
|
||||
|
||||
if things == 0 {
|
||||
s.push_str(EMPTY);
|
||||
s.push_str(&footer(updated_epoch));
|
||||
s.push_str(SCRIPT);
|
||||
s.push_str(TAIL);
|
||||
return s;
|
||||
}
|
||||
|
||||
// per-network board(s): champion podium + the rest as a ledger
|
||||
for b in boards {
|
||||
if b.rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
s.push_str("<section class=\"board fade\">");
|
||||
if multi {
|
||||
s.push_str(&format!(
|
||||
"<div class=\"eyebrow\">network<span>{}</span></div>",
|
||||
esc(&b.network)
|
||||
));
|
||||
}
|
||||
s.push_str(&podium(b));
|
||||
s.push_str(&ledger(b));
|
||||
s.push_str("</section>");
|
||||
}
|
||||
|
||||
// activity + givers band
|
||||
s.push_str("<div class=\"band\">");
|
||||
s.push_str(&activity(&feed, multi));
|
||||
s.push_str(&givers_panel(&feed));
|
||||
s.push_str("</div>");
|
||||
|
||||
s.push_str(&footer(updated_epoch));
|
||||
s.push_str(SCRIPT);
|
||||
s.push_str(TAIL);
|
||||
s
|
||||
}
|
||||
|
||||
fn row(rank: usize, r: &Row) -> String {
|
||||
let medal = match rank {
|
||||
1 => "🥇".to_string(),
|
||||
2 => "🥈".to_string(),
|
||||
3 => "🥉".to_string(),
|
||||
n => n.to_string(),
|
||||
};
|
||||
let top = if rank <= 3 { " top" } else { "" };
|
||||
let (klass, sign) = match r.count {
|
||||
n if n > 0 => ("pos", "+"),
|
||||
n if n < 0 => ("neg", ""),
|
||||
_ => ("zero", ""),
|
||||
};
|
||||
let mut who = format!("<div class=\"name\">{}</div>", esc(&r.thing));
|
||||
if let Some(reason) = &r.reason {
|
||||
match &r.by {
|
||||
Some(by) => who.push_str(&format!(
|
||||
"<div class=\"reason\">\u{201C}{}\u{201D} <span class=\"by\">\u{2014} {}</span></div>",
|
||||
esc(reason),
|
||||
esc(by)
|
||||
)),
|
||||
None => who.push_str(&format!(
|
||||
"<div class=\"reason\">\u{201C}{}\u{201D}</div>",
|
||||
esc(reason)
|
||||
)),
|
||||
}
|
||||
}
|
||||
fn stat(num: &str, label: &str) -> String {
|
||||
format!(
|
||||
"<li class=\"row\"><span class=\"rank{top}\">{medal}</span>\
|
||||
<div class=\"who\">{who}</div>\
|
||||
<span class=\"score {klass}\">{sign}{}</span></li>",
|
||||
r.count
|
||||
"<div class=\"stat\"><span class=\"num\">{}</span><span class=\"lab\">{}</span></div>",
|
||||
esc(num),
|
||||
esc(label)
|
||||
)
|
||||
}
|
||||
|
||||
fn podium(b: &Board) -> String {
|
||||
let top = b.rows.iter().take(3).collect::<Vec<_>>();
|
||||
let mut out = String::from("<ol class=\"podium\">");
|
||||
for (i, r) in top.iter().enumerate() {
|
||||
let rank = i + 1;
|
||||
let crown = match rank {
|
||||
1 => "👑",
|
||||
2 => "🥈",
|
||||
3 => "🥉",
|
||||
_ => "",
|
||||
};
|
||||
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
|
||||
let reason = match (&r.reason, &r.by) {
|
||||
(Some(why), Some(by)) => format!(
|
||||
"<div class=\"why\">\u{201C}{}\u{201D} <span class=\"by\">\u{2014} {}</span></div>",
|
||||
esc(why),
|
||||
esc(by)
|
||||
),
|
||||
(Some(why), None) => format!("<div class=\"why\">\u{201C}{}\u{201D}</div>", esc(why)),
|
||||
_ => String::new(),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"<li class=\"pillar r{rank}\"><div class=\"crown\">{crown}</div>{}\
|
||||
<div class=\"nick\">{}</div><div class=\"score {}\">{}</div>{spark}{reason}</li>",
|
||||
avatar(&r.thing, "lg"),
|
||||
esc(&r.thing),
|
||||
klass(r.count),
|
||||
signed(r.count),
|
||||
));
|
||||
}
|
||||
out.push_str("</ol>");
|
||||
out
|
||||
}
|
||||
|
||||
fn ledger(b: &Board) -> String {
|
||||
if b.rows.len() <= 3 {
|
||||
return String::new();
|
||||
}
|
||||
let mut out = String::from("<div class=\"eyebrow\">the ledger</div><ol class=\"list\">");
|
||||
for (i, r) in b.rows.iter().enumerate().skip(3) {
|
||||
let rank = i + 1;
|
||||
let spark = sparkline(&series_for(&r.thing, r.count, &b.events));
|
||||
let reason = match (&r.reason, &r.by) {
|
||||
(Some(why), Some(by)) => format!(
|
||||
"<span class=\"why\">\u{201C}{}\u{201D} \u{2014} {}</span>",
|
||||
esc(why),
|
||||
esc(by)
|
||||
),
|
||||
(Some(why), None) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(why)),
|
||||
_ => String::new(),
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"<li class=\"row\"><span class=\"rank\">{rank}</span>{}\
|
||||
<div class=\"who\"><div class=\"nick\">{}</div>{reason}</div>\
|
||||
{spark}<span class=\"score {}\">{}</span></li>",
|
||||
avatar(&r.thing, "sm"),
|
||||
esc(&r.thing),
|
||||
klass(r.count),
|
||||
signed(r.count),
|
||||
));
|
||||
}
|
||||
out.push_str("</ol>");
|
||||
out
|
||||
}
|
||||
|
||||
fn activity(feed: &[(&str, &Event)], multi: bool) -> String {
|
||||
let mut out = String::from(
|
||||
"<section class=\"panel fade\"><div class=\"eyebrow live-h\">\
|
||||
<span class=\"live\"></span>recent activity</div>",
|
||||
);
|
||||
if feed.is_empty() {
|
||||
out.push_str(
|
||||
"<div class=\"empty-panel\">Nothing yet. The next <code>nick++</code> shows up here.</div></section>",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
out.push_str("<ul class=\"timeline\">");
|
||||
for (net, e) in feed.iter().take(40) {
|
||||
let actor = e.by.clone().unwrap_or_else(|| "someone".to_string());
|
||||
let dchip = if e.delta >= 0 { "++" } else { "--" };
|
||||
let why = match &e.why {
|
||||
Some(w) => format!("<span class=\"why\">\u{201C}{}\u{201D}</span>", esc(w)),
|
||||
None => String::new(),
|
||||
};
|
||||
let netlabel = if multi {
|
||||
format!("<span class=\"net\">{}</span>", esc(net))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"<li class=\"ev {}\">{}<div class=\"evbody\"><div class=\"evline\">\
|
||||
<b>{}</b> <span class=\"verb\">gave</span> <b>{}</b> \
|
||||
<span class=\"chip {}\">{dchip}</span>{netlabel}</div>{why}\
|
||||
<time class=\"ago\" data-t=\"{}\"></time></div></li>",
|
||||
klass(e.delta),
|
||||
avatar(&actor, "sm"),
|
||||
esc(&actor),
|
||||
esc(&e.thing),
|
||||
klass(e.delta),
|
||||
e.t,
|
||||
));
|
||||
}
|
||||
out.push_str("</ul></section>");
|
||||
out
|
||||
}
|
||||
|
||||
fn givers_panel(feed: &[(&str, &Event)]) -> String {
|
||||
let givers = top_givers(feed, 8);
|
||||
let mut out =
|
||||
String::from("<section class=\"panel fade\"><div class=\"eyebrow\">top givers</div>");
|
||||
if givers.is_empty() {
|
||||
out.push_str(
|
||||
"<div class=\"empty-panel\">No one has handed out karma yet. Be the first.</div></section>",
|
||||
);
|
||||
return out;
|
||||
}
|
||||
out.push_str("<ol class=\"list givers\">");
|
||||
for (i, g) in givers.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"<li class=\"row\"><span class=\"rank\">{}</span>{}\
|
||||
<div class=\"who\"><div class=\"nick\">{}</div>\
|
||||
<span class=\"why\">{} gift{}</span></div>\
|
||||
<span class=\"score {}\">{}</span></li>",
|
||||
i + 1,
|
||||
avatar(&g.name, "sm"),
|
||||
esc(&g.name),
|
||||
g.gives,
|
||||
if g.gives == 1 { "" } else { "s" },
|
||||
klass(g.points),
|
||||
signed(g.points),
|
||||
));
|
||||
}
|
||||
out.push_str("</ol></section>");
|
||||
out
|
||||
}
|
||||
|
||||
fn footer(epoch: u64) -> String {
|
||||
format!(
|
||||
"<footer class=\"fade\">give karma by saying <code>nick++</code> in the channel \
|
||||
· built by <a href=\"https://git.devtronic.pro/reverse/rubot\">rubot</a> \
|
||||
· updated <time class=\"ago\" data-t=\"{epoch}\"></time></footer>"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -141,70 +478,154 @@ const HEAD: &str = r##"<!doctype html>
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>karma — devtronic</title>
|
||||
<title>karma · devtronic</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ctext y='13' font-size='13'%3E%E2%9C%A8%3C/text%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root{--bg:#0f1216;--card:#171b21;--card2:#1b2028;--line:#252b34;--fg:#e6e9ee;--muted:#8b95a3;--pos:#4ade80;--neg:#f87171;--zero:#8b95a3;--accent:#7c9cff}
|
||||
:root{
|
||||
--bg:#12131a;--bg2:#0c0d12;--panel:#191b25;--panel2:#20232f;--line:#2b2e3d;
|
||||
--fg:#ece9f5;--muted:#8d8fab;--faint:#5c5e75;
|
||||
--gold:#f5b642;--up:#5fd39f;--down:#f2757a;
|
||||
--mono:ui-monospace,"SF Mono","JetBrains Mono","Cascadia Code",Menlo,Consolas,monospace;
|
||||
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
|
||||
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
|
||||
.wrap{max-width:720px;margin:0 auto;padding:32px 18px 64px}
|
||||
header{text-align:center;margin-bottom:24px}
|
||||
h1{margin:0;font-size:34px;letter-spacing:-.02em}
|
||||
.sub{margin:6px 0 0;color:var(--muted);font-size:14px}
|
||||
.search{width:100%;margin:0 0 22px;padding:11px 14px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--fg);font-size:15px}
|
||||
.search:focus{outline:none;border-color:var(--accent)}
|
||||
.netsec{margin-top:26px}
|
||||
.net{display:flex;align-items:baseline;gap:10px;margin:0 2px 10px}
|
||||
.net h2{margin:0;font-size:15px;font-weight:600;text-transform:lowercase}
|
||||
.net .count{margin-left:auto;color:var(--muted);font-size:12px}
|
||||
ol.board{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
|
||||
li.row{display:flex;align-items:center;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px}
|
||||
li.row:hover{background:var(--card2)}
|
||||
.rank{flex:none;width:30px;text-align:center;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.rank.top{font-size:18px}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 var(--sans);
|
||||
background-image:radial-gradient(900px 380px at 50% -140px,rgba(245,182,66,.10),transparent 70%);
|
||||
background-repeat:no-repeat}
|
||||
.page{max-width:860px;margin:0 auto;padding:40px 20px 72px}
|
||||
a{color:var(--gold);text-decoration:none}a:hover{text-decoration:underline}
|
||||
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);border:1px solid var(--line);
|
||||
border-radius:6px;padding:1px 6px;color:var(--gold)}
|
||||
:focus-visible{outline:2px solid var(--gold);outline-offset:2px;border-radius:6px}
|
||||
|
||||
/* hero */
|
||||
.hero{text-align:center;padding:14px 0 30px}
|
||||
.prompt{font-family:var(--mono);font-size:12px;letter-spacing:.06em;color:var(--faint);margin-bottom:10px}
|
||||
.prompt::before{content:"$ ";color:var(--up)}
|
||||
.mark{font-family:var(--mono);font-weight:700;font-size:clamp(46px,12vw,84px);line-height:.9;
|
||||
margin:0;letter-spacing:-.04em;
|
||||
background:linear-gradient(180deg,#fff,#c9c7db);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.cursor{color:var(--gold);-webkit-text-fill-color:var(--gold)}
|
||||
.tag{color:var(--muted);max-width:44ch;margin:16px auto 0;text-wrap:balance}
|
||||
.stats{display:flex;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:26px}
|
||||
.stat{background:var(--panel);border:1px solid var(--line);border-radius:14px;
|
||||
padding:12px 18px;min-width:104px;display:flex;flex-direction:column;gap:2px}
|
||||
.stat .num{font-family:var(--mono);font-size:24px;font-weight:700;font-variant-numeric:tabular-nums}
|
||||
.stat .lab{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--muted)}
|
||||
|
||||
.eyebrow{font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);
|
||||
margin:34px 2px 14px;display:flex;align-items:center;gap:10px}
|
||||
.eyebrow span{color:var(--gold);font-family:var(--mono);letter-spacing:.04em}
|
||||
|
||||
/* podium */
|
||||
.podium{list-style:none;margin:0;padding:0;display:flex;justify-content:center;align-items:flex-end;
|
||||
gap:14px;flex-wrap:wrap}
|
||||
.pillar{background:linear-gradient(180deg,var(--panel2),var(--panel));border:1px solid var(--line);
|
||||
border-radius:18px;padding:20px 18px 18px;width:210px;display:flex;flex-direction:column;
|
||||
align-items:center;gap:6px;position:relative}
|
||||
.pillar.r1{order:2;width:236px;padding-top:26px;border-color:rgba(245,182,66,.45);
|
||||
box-shadow:0 0 0 1px rgba(245,182,66,.18),0 20px 50px -24px rgba(245,182,66,.4)}
|
||||
.pillar.r2{order:1}.pillar.r3{order:3}
|
||||
.crown{font-size:22px;line-height:1;height:22px}
|
||||
.pillar .nick{font-family:var(--mono);font-weight:600;font-size:17px;word-break:break-word;text-align:center}
|
||||
.pillar .score{font-family:var(--mono);font-weight:700;font-size:30px;font-variant-numeric:tabular-nums}
|
||||
.pillar .why{font-size:12px;color:var(--muted);text-align:center;text-wrap:balance;margin-top:2px}
|
||||
.pillar .by{color:var(--faint)}
|
||||
|
||||
/* avatars */
|
||||
.ava{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;
|
||||
font-family:var(--mono);font-weight:600;color:#fff;flex:none;
|
||||
background:hsl(var(--h) 42% 42%);box-shadow:inset 0 0 0 1px rgba(255,255,255,.12)}
|
||||
.ava.lg{width:56px;height:56px;font-size:19px}
|
||||
.ava.sm{width:34px;height:34px;font-size:13px}
|
||||
|
||||
/* score colors */
|
||||
.score.up,.chip.up{color:var(--up)}.score.down,.chip.down{color:var(--down)}.score.zero{color:var(--muted)}
|
||||
|
||||
/* sparkline */
|
||||
.spark{width:104px;height:30px;flex:none}
|
||||
.spark polyline{fill:none;stroke-width:1.6;vector-effect:non-scaling-stroke;stroke-linejoin:round;stroke-linecap:round}
|
||||
.spark.up polyline{stroke:var(--up)}.spark.down polyline{stroke:var(--down)}
|
||||
.spark.up circle{fill:var(--up)}.spark.down circle{fill:var(--down)}
|
||||
.pillar .spark{width:150px;margin-top:4px}
|
||||
|
||||
/* ledger + list rows */
|
||||
.list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
|
||||
.row{display:flex;align-items:center;gap:13px;background:var(--panel);border:1px solid var(--line);
|
||||
border-radius:13px;padding:11px 15px}
|
||||
.row:hover{background:var(--panel2)}
|
||||
.rank{font-family:var(--mono);color:var(--faint);font-size:13px;width:22px;text-align:right;flex:none;
|
||||
font-variant-numeric:tabular-nums}
|
||||
.who{flex:1;min-width:0}
|
||||
.name{font-weight:600;word-break:break-word}
|
||||
.reason{color:var(--muted);font-size:13px;margin-top:2px;word-break:break-word}
|
||||
.reason .by{color:var(--fg);opacity:.8}
|
||||
.score{flex:none;font-variant-numeric:tabular-nums;font-weight:700;font-size:17px;min-width:46px;text-align:right}
|
||||
.score.pos{color:var(--pos)}.score.neg{color:var(--neg)}.score.zero{color:var(--zero)}
|
||||
.empty{text-align:center;color:var(--muted);padding:48px 6px}
|
||||
.empty code,footer code{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;color:var(--fg)}
|
||||
footer{text-align:center;color:var(--muted);font-size:12px;margin-top:44px}
|
||||
.nick{font-family:var(--mono);font-weight:600;word-break:break-word}
|
||||
.why{display:block;color:var(--muted);font-size:12.5px;margin-top:1px;word-break:break-word}
|
||||
.row .score{font-family:var(--mono);font-weight:700;font-size:18px;font-variant-numeric:tabular-nums;
|
||||
min-width:46px;text-align:right;flex:none}
|
||||
|
||||
/* activity + givers band */
|
||||
.band{display:grid;grid-template-columns:1fr;gap:16px;margin-top:8px}
|
||||
@media(min-width:720px){.band{grid-template-columns:1.35fr 1fr;align-items:start}}
|
||||
.panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:6px 18px 18px}
|
||||
.panel .eyebrow{margin-top:18px}
|
||||
.empty-panel{color:var(--muted);font-size:14px;padding:10px 2px 14px}
|
||||
.live-h .live{width:8px;height:8px;border-radius:50%;background:var(--up);
|
||||
box-shadow:0 0 0 4px rgba(95,211,159,.16)}
|
||||
.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column}
|
||||
.ev{display:flex;gap:12px;padding:11px 0;border-bottom:1px solid var(--line)}
|
||||
.ev:last-child{border-bottom:0}
|
||||
.evbody{min-width:0;flex:1}
|
||||
.evline{word-break:break-word}.evline b{font-family:var(--mono);font-weight:600}
|
||||
.verb{color:var(--muted)}
|
||||
.chip{font-family:var(--mono);font-weight:700;font-size:12px;border:1px solid var(--line);
|
||||
border-radius:6px;padding:0 6px;margin-left:2px}
|
||||
.chip.up{border-color:rgba(95,211,159,.4)}.chip.down{border-color:rgba(242,117,122,.4)}
|
||||
.net{font-family:var(--mono);font-size:11px;color:var(--faint);margin-left:6px}
|
||||
.ago{display:block;color:var(--faint);font-size:11.5px;margin-top:2px;font-variant-numeric:tabular-nums}
|
||||
|
||||
footer{text-align:center;color:var(--muted);font-size:12.5px;margin-top:44px;line-height:2}
|
||||
|
||||
/* empty (no karma at all) */
|
||||
.empty{text-align:center;color:var(--muted);padding:56px 10px}
|
||||
|
||||
@media(max-width:560px){
|
||||
.pillar,.pillar.r1{width:100%;order:0}
|
||||
.pillar.r1{order:-1}
|
||||
.row{gap:10px;padding:10px 12px}
|
||||
.row .spark{display:none}
|
||||
}
|
||||
@media(prefers-reduced-motion:no-preference){
|
||||
.fade{animation:rise .55s cubic-bezier(.2,.7,.2,1) both}
|
||||
.board{animation-delay:.05s}.band{animation-delay:.1s}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
|
||||
.cursor{animation:blink 1.15s step-end infinite}
|
||||
@keyframes blink{50%{opacity:0}}
|
||||
.live{animation:pulse 1.9s ease-in-out infinite}
|
||||
@keyframes pulse{50%{opacity:.4;box-shadow:0 0 0 6px rgba(95,211,159,0)}}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<header>
|
||||
<h1>✨ karma</h1>
|
||||
<p class="sub">community karma, tracked by <a href="https://git.devtronic.pro/reverse/rubot">rubot</a></p>
|
||||
</header>
|
||||
<div class="page">
|
||||
"##;
|
||||
|
||||
const SEARCH: &str = r##"<input id="q" class="search" type="search" placeholder="filter…" autocomplete="off" aria-label="filter">"##;
|
||||
const EMPTY: &str = r##"<div class="empty fade">No karma yet.<br>Say <code>nick++</code> in the channel and this board comes alive.</div>"##;
|
||||
|
||||
const EMPTY: &str = r##"<div class="empty">no karma yet — say <code>nick++</code> in the channel to give some.</div>"##;
|
||||
|
||||
const SCRIPT: &str = r##"<footer>say <code>nick++</code> in the channel to give karma · <a href="https://git.devtronic.pro/reverse/rubot">rubot</a> · updated <span id="up"></span></footer>
|
||||
</main>
|
||||
<script>
|
||||
const SCRIPT: &str = r##"<script>
|
||||
(function(){
|
||||
var q=document.getElementById('q');
|
||||
if(q){q.addEventListener('input',function(){
|
||||
var t=q.value.toLowerCase();
|
||||
document.querySelectorAll('section.netsec').forEach(function(sec){
|
||||
var any=false;
|
||||
sec.querySelectorAll('li.row').forEach(function(li){
|
||||
var show=li.textContent.toLowerCase().indexOf(t)>=0;
|
||||
li.style.display=show?'':'none';if(show)any=true;});
|
||||
sec.style.display=any?'':'none';});
|
||||
});}
|
||||
var up=document.getElementById('up');
|
||||
if(up){try{up.textContent=new Date(__EPOCH__*1000).toLocaleString();}catch(e){}}
|
||||
setTimeout(function(){if(!q||!q.value)location.reload();},60000);
|
||||
function ago(sec){
|
||||
var d=Math.max(0,Math.floor(Date.now()/1000)-sec);
|
||||
if(d<45)return"just now";
|
||||
var u=[[31536000,"y"],[2592000,"mo"],[604800,"w"],[86400,"d"],[3600,"h"],[60,"m"]];
|
||||
for(var i=0;i<u.length;i++){if(d>=u[i][0])return Math.floor(d/u[i][0])+u[i][1]+" ago";}
|
||||
return Math.floor(d/1)+"s ago";
|
||||
}
|
||||
document.querySelectorAll("time.ago[data-t]").forEach(function(t){
|
||||
var v=parseInt(t.getAttribute("data-t"),10);if(v)t.textContent=ago(v);
|
||||
});
|
||||
setTimeout(function(){location.reload();},60000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"##;
|
||||
|
||||
const TAIL: &str = "</div></body></html>\n";
|
||||
|
|
|
|||
|
|
@ -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