use std::collections::BTreeSet;
use std::time::Duration;
use super::*;
fn ribbon() -> Ribbon {
Ribbon {
host: "git.klbr.net".into(),
path: "ptr.pet / hydrant".into(),
}
}
fn cards() -> Vec<(&'static str, Card)> {
let diff = Some(Diff {
insertions: 184,
deletions: 97,
files: 7,
});
vec![
(
"repo",
Card::Repo {
ribbon: Ribbon {
host: "git.klbr.net".into(),
path: "ptr.pet".into(),
},
name: "hydrant".into(),
description: "very fast at protocol indexer with flexible filtering, xrpc queries, cursor-backed event stream, and more, built on fjall".into(),
topics: vec!["rust".into(), "fjall".into(), "at-protocol".into()],
open_tickets: 10,
open_pulls: 1,
branch: "main".into(),
languages: vec![
Language { name: "Rust".into(), size: 940 },
Language { name: "TypeScript".into(), size: 30 },
Language { name: "CSS".into(), size: 10 },
],
},
),
(
"ticket",
Card::Ticket {
ribbon: ribbon(),
number: "1".into(),
title: "[api] provide a way to query failed crawls and allow retrying them / ignoring them".into(),
state: "open".into(),
author: "@ptr.pet".into(),
date: "2026-10-28".into(),
body: "GET /crawls/failed ? DELETE /crawls/failed ?".into(),
},
),
(
"pull",
Card::Pull {
ribbon: ribbon(),
number: "63".into(),
title: "update oauth path so only domain is shown".into(),
state: "merged".into(),
author: "@xan.lol".into(),
hue: 2,
seed: "63".into(),
date: "2026-08-14".into(),
from: "fork:main".into(),
into: "main".into(),
body: "rounds the redirect through the oauth client".into(),
diff: diff.clone(),
},
),
(
"pull-open",
Card::Pull {
ribbon: ribbon(),
number: "41".into(),
title: "refetch budget: spend the hourly allowance evenly".into(),
state: "open".into(),
author: "@ptr.pet".into(),
hue: 0,
seed: "41".into(),
date: "2026-11-11".into(),
from: "refetch-budget-v1-with-a-long-branch-name".into(),
into: "main".into(),
body: "spreads host refetches over the hour instead of front-loading them".into(),
diff: diff.clone(),
},
),
(
"commit",
Card::Commit {
ribbon: ribbon(),
subject: "[backfill] walk the mst while a filtered car streams".into(),
short: "0021f39".into(),
author: "dawn".into(),
date: "2026-11-05".into(),
branch: "main".into(),
body: "the car reader hands each block to the walk as it lands".into(),
diff,
},
),
(
"user",
Card::User {
host: "git.klbr.net".into(),
handle: "@ptr.pet".into(),
about: "swe @tangled.org, writes indexers, knots and the odd yarn ball, mostly in rust and mostly at night".into(),
repos: vec!["hydrant".into(), "svfe".into()],
repo_count: 2,
avatar: Some(vec![200; (TILE * TILE * 3) as usize]),
},
),
]
}
#[test]
fn every_kind_draws_a_card_unfurls_take() {
let theme = theme();
for (kind, card) in cards() {
let png = draw::draw(&card, &theme).unwrap();
let image = image::load_from_memory(&png).unwrap();
assert_eq!((image.width(), image.height()), (1200, 630), "{kind}");
// whatsapp's practical cap is 300 KB, the cards stay far under it
assert!(png.len() < 60_000, "{kind}: {} bytes", png.len());
if let Ok(dir) = std::env::var("OG_CARDS_OUT") {
crate::cas::atomic_write(&Path::new(&dir).join(format!("{kind}.png")), &png).unwrap();
}
}
}
#[test]
fn a_card_without_an_avatar_or_languages_still_draws() {
let theme = theme();
for (_, mut card) in cards() {
match &mut card {
Card::User { avatar, about, .. } => {
*avatar = None;
about.clear();
}
Card::Repo {
languages,
topics,
description,
..
} => {
languages.clear();
topics.clear();
description.clear();
}
_ => continue,
}
draw::draw(&card, &theme).unwrap();
}
}
#[test]
fn a_long_title_wraps_to_two_lines_and_ends_in_an_ellipsis() {
let lines = canvas::Face::SansBold.wrap(60.0, 800.0, 1000.0, 2, &"word ".repeat(80));
assert_eq!(lines.len(), 2);
assert!(lines[1].ends_with('…'), "{lines:?}");
assert!(canvas::Face::SansBold.width(60.0, &lines[0]) <= 800.0);
}
fn ticket(path: &str, title: &str) -> Planned {
Planned {
path: path.into(),
key: key(&title).unwrap(),
card: Lazy::Ready(Card::Ticket {
ribbon: ribbon(),
number: "1".into(),
title: title.into(),
state: "open".into(),
author: "@ptr.pet".into(),
date: "2026-10-28".into(),
body: String::new(),
}),
}
}
/// a repo whose one ticket is titled `title`
fn set(title: &str) -> Set {
Set {
did: "did:plc:repo".into(),
name: key(&("set", title)).unwrap(),
cards: vec![ticket(REPO_CARD, title), ticket("issues/1.png", title)],
}
}
/// what generate does: plan the set, drawn unless it's there, and emit it into a generation
fn generation(root: &Path, id: &str, title: &str) -> PathBuf {
let dir = root.join("generations").join(id);
let mut set = set(title);
if root.join(CARDS_DIR).join(SETS_DIR).join(&set.name).is_dir() {
set.cards.clear();
}
let plan = Plan {
sets: vec![set],
users: vec![ticket("did:plc:owner.png", "owner")],
};
emit(root, &dir, plan).unwrap();
dir
}
fn served(generation: &Path, path: &str) -> std::fs::Metadata {
std::fs::metadata(generation.join("www").join(WWW_DIR).join(path)).unwrap()
}
#[test]
fn a_set_is_drawn_once_and_every_generation_links_it() {
use std::os::unix::fs::MetadataExt as _;
let root = tempfile::tempdir().unwrap();
let first = generation(root.path(), "a", "one");
let second = generation(root.path(), "b", "one");
let www = first.join("www").join(WWW_DIR);
assert!(
std::fs::symlink_metadata(www.join("did:plc:repo"))
.unwrap()
.is_symlink()
);
assert!(
std::fs::symlink_metadata(www.join("did:plc:repo.png"))
.unwrap()
.is_symlink()
);
for path in [
"did:plc:repo.png",
"did:plc:repo/issues/1.png",
"did:plc:owner.png",
] {
assert_eq!(
served(&first, path).ino(),
served(&second, path).ino(),
"{path}"
);
}
let sets = std::fs::read_dir(root.path().join(CARDS_DIR).join(SETS_DIR)).unwrap();
assert_eq!(sets.count(), 1);
}
#[test]
fn a_sweep_keeps_what_a_kept_generation_names() {
use std::os::unix::fs::MetadataExt as _;
let root = tempfile::tempdir().unwrap();
let old = generation(root.path(), "a", "one");
let new = generation(root.path(), "b", "two");
let named = BTreeSet::from([set("two").name]);
sweep(root.path(), &named, &BTreeSet::new(), Duration::ZERO).unwrap();
// a rollback to the older generation still serves its own ticket card
let drawn = |title: &str| {
let file = root
.path()
.join(CARDS_DIR)
.join(format!("{}.png", key(&title).unwrap()));
std::fs::metadata(file).unwrap().ino()
};
assert_eq!(
served(&old, "did:plc:repo/issues/1.png").ino(),
drawn("one")
);
assert_eq!(
served(&new, "did:plc:repo/issues/1.png").ino(),
drawn("two")
);
// once its generation is gone, so are its set and the cards only it linked
std::fs::remove_dir_all(&old).unwrap();
sweep(root.path(), &named, &BTreeSet::new(), Duration::ZERO).unwrap();
let sets = root.path().join(CARDS_DIR).join(SETS_DIR);
assert!(!sets.join(set("one").name).exists());
assert!(sets.join(set("two").name).is_dir());
let cards: Vec<_> = std::fs::read_dir(root.path().join(CARDS_DIR))
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".png"))
.collect();
assert_eq!(
cards,
[format!("{}.png", key(&"two").unwrap())],
"{cards:?}"
);
assert_eq!(served(&new, "did:plc:repo.png").ino(), drawn("two"));
}
#[test]
fn a_message_splits_into_its_subject_and_its_body_as_prose() {
let (subject, body) =
split_message("fix: a thing\n\n## why\n\n- it *was* broken\n```\ncode\n```\nnow `fixed`\n");
assert_eq!(subject, "fix: a thing");
assert_eq!(body, "why it was broken now fixed");
}