Tests found by mutation testing: Kick's going-live, deleted-message, chat-clear, chat-mode and pin events, gifts' tiers, a sender's style and a reply's preview; YouTube ids with hyphens and underscores in every link form; the GIF resolver's fields and limits; a mute's end time; and a forgotten account. One of them found a real fault - an unpinned message never left the screen, because its event name ends the way a deleted message's does - which is fixed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
@@ -216,4 +216,42 @@ mod tests {
|
||||
let forever = json!({ "guild_id": "9", "muted": true });
|
||||
assert!(parse_entry(&forever, NOW).1.muted);
|
||||
}
|
||||
|
||||
/// A timed mute ends at its end time, to the second: at that moment it is
|
||||
/// over, and a second before it is not.
|
||||
#[test]
|
||||
fn a_mute_is_over_at_its_end_time() {
|
||||
let ends = |at: i64| {
|
||||
let end = chrono::DateTime::from_timestamp(at, 0).unwrap().to_rfc3339();
|
||||
json!({ "muted": true, "mute_config": { "end_time": end } })
|
||||
};
|
||||
assert!(still_muted(&ends(NOW + 1), NOW), "a second to go");
|
||||
assert!(!still_muted(&ends(NOW), NOW), "its moment");
|
||||
assert!(!still_muted(&ends(NOW - 1), NOW), "past");
|
||||
// An end time nobody can read is not a reason to say it is over.
|
||||
assert!(still_muted(&json!({ "muted": true, "mute_config": { "end_time": "someday" } }), NOW));
|
||||
assert!(!still_muted(&json!({ "muted": false, "mute_config": null }), NOW), "never muted");
|
||||
}
|
||||
|
||||
/// Settings in the three shapes, and an entry that is not an object at all
|
||||
/// is skipped rather than read as an empty one.
|
||||
#[test]
|
||||
fn what_is_read_is_kept_and_what_is_not_an_entry_is_skipped() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("discord-mutes");
|
||||
let acct = "discord:me";
|
||||
note_settings(&state, acct, &json!([
|
||||
{ "guild_id": "9", "muted": true },
|
||||
"not an entry",
|
||||
42,
|
||||
{ "guild_id": null, "muted": false, "channel_overrides": [{ "channel_id": "5", "muted": true }] }
|
||||
]));
|
||||
assert!(state.runtime.discord_muted(acct, "9", "anything"), "a muted guild mutes what is in it");
|
||||
assert!(state.runtime.discord_muted(acct, DM_SCOPE, "5"), "a muted direct message");
|
||||
assert!(!state.runtime.discord_muted(acct, DM_SCOPE, "6"));
|
||||
// One entry on its own, as an update sends it, and the wrapped form.
|
||||
note_settings(&state, acct, &json!({ "guild_id": "9", "muted": false }));
|
||||
assert!(!state.runtime.discord_muted(acct, "9", "anything"));
|
||||
note_settings(&state, acct, &json!({ "entries": [{ "guild_id": "9", "muted": true }] }));
|
||||
assert!(state.runtime.discord_muted(acct, "9", "anything"));
|
||||
}
|
||||
}
|
||||
|
||||
+129
-1
@@ -241,7 +241,10 @@ pub(super) fn handle_event(
|
||||
// too, or moho shows a different chat from the one everybody else is
|
||||
// looking at - which on a stream where moderation is public is the
|
||||
// wrong way round entirely.
|
||||
e if e.ends_with("MessageDeletedEvent") => {
|
||||
// Not a pin coming down, which Kick calls `PinnedMessageDeletedEvent` and
|
||||
// which ends the same way: that is the pin's, below, and taking it for a
|
||||
// message deleted left a pin on screen for ever after it was removed.
|
||||
e if e.ends_with("MessageDeletedEvent") && !pins::is_pin_event(e) => {
|
||||
let Some(slug) = channel_of(watched, &frame.channel, &payload) else { return Ok(()) };
|
||||
// The id is nested under `message` on this event and flat on
|
||||
// others Kick has sent; both name the same thing.
|
||||
@@ -1193,4 +1196,129 @@ mod tests {
|
||||
assert_eq!((rows[1].kind.as_str(), rows[1].body.as_str()), ("sub", "kufallen gifted 2 subscriptions"));
|
||||
assert_eq!(rows[1].embeds[0].description.as_deref(), Some("a\nb"));
|
||||
}
|
||||
|
||||
fn spoken(state: &AppState, watched: &mut Watched, id: &str, text: &str) {
|
||||
feed(state, watched, "App\\Events\\ChatMessageEvent", serde_json::json!({
|
||||
"id": id, "chatroom_id": 2393554, "content": text, "sender": { "id": 9, "username": "bob", "identity": null }
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn going_live_and_ending_are_said_in_the_channel() {
|
||||
let state = testkit::simulated_daemon("live-lines");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
feed(&state, &mut watched, "App\\Events\\StreamerIsLive", serde_json::json!({}));
|
||||
feed(&state, &mut watched, "App\\Events\\StopStreamBroadcast", serde_json::json!({}));
|
||||
assert_eq!(testkit::lines(&state, "odablock"), vec!["went live", "ended the stream"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_taken_down_leaves_the_log() {
|
||||
let state = testkit::simulated_daemon("deleted");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
spoken(&state, &mut watched, "keep", "stays");
|
||||
spoken(&state, &mut watched, "nested", "goes");
|
||||
spoken(&state, &mut watched, "flat", "goes too");
|
||||
// Kick nests the id under `message` on one event and sends it flat on another.
|
||||
feed(&state, &mut watched, "App\\Events\\MessageDeletedEvent", serde_json::json!({ "message": { "id": "nested" } }));
|
||||
feed(&state, &mut watched, "App\\Events\\MessageDeletedEvent", serde_json::json!({ "id": "flat" }));
|
||||
assert_eq!(testkit::lines(&state, "odablock"), vec!["stays"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moderator_emptying_the_room_is_said() {
|
||||
let state = testkit::simulated_daemon("cleared");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomClearEvent", serde_json::json!({}));
|
||||
assert_eq!(testkit::lines(&state, "odablock"), vec!["a moderator cleared the chat"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chat_rules_changing_are_kept_for_the_next_message_to_be_explained_by() {
|
||||
let state = testkit::simulated_daemon("chat-modes");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
let buffer = crate::model::buffer_id("kick:tester", "odablock");
|
||||
state.runtime.set_kick_channel(&buffer, crate::runtime::KickChannel {
|
||||
slug: "odablock".into(), channel_id: 2401072, history_cursor: None, chatroom_id: 2393554, subscribed: false,
|
||||
followers_only: false, subscribers_only: false, slow_seconds: None, emotes: Vec::new(),
|
||||
});
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomUpdatedEvent", serde_json::json!({
|
||||
"followers_mode": { "enabled": true },
|
||||
"subscribers_mode": { "enabled": false },
|
||||
"slow_mode": { "enabled": true, "message_interval": 30 }
|
||||
}));
|
||||
let now = state.runtime.kick_channel(&buffer).unwrap();
|
||||
assert_eq!((now.followers_only, now.subscribers_only, now.slow_seconds), (true, false, Some(30)));
|
||||
// A slow mode that is switched off has no interval, whatever it still says.
|
||||
feed(&state, &mut watched, "App\\Events\\ChatroomUpdatedEvent", serde_json::json!({
|
||||
"subscribers_mode": { "enabled": true }, "slow_mode": { "enabled": false, "message_interval": 30 }
|
||||
}));
|
||||
let now = state.runtime.kick_channel(&buffer).unwrap();
|
||||
assert_eq!((now.followers_only, now.subscribers_only, now.slow_seconds), (false, true, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pin_and_its_removal_reach_the_channel() {
|
||||
let state = testkit::simulated_daemon("pins");
|
||||
let mut watched = testkit::watching_odablock();
|
||||
let buffer = crate::model::buffer_id("kick:tester", "odablock");
|
||||
feed(&state, &mut watched, "App\\Events\\PinnedMessageCreatedEvent", serde_json::json!({
|
||||
"message": { "id": "p1", "content": "read the rules", "sender": { "username": "mod" } }
|
||||
}));
|
||||
assert_eq!(state.runtime.kick_pin(&buffer).map(|p| p.body), Some("read the rules".to_string()));
|
||||
// An event about a pin that says nothing readable leaves the pin up.
|
||||
feed(&state, &mut watched, "App\\Events\\PinnedMessageCreatedEvent", serde_json::json!({}));
|
||||
assert!(state.runtime.kick_pin(&buffer).is_some());
|
||||
feed(&state, &mut watched, "App\\Events\\PinnedMessageDeletedEvent", serde_json::json!({}));
|
||||
assert!(state.runtime.kick_pin(&buffer).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gifts_tier_is_a_colour_and_an_ordinary_one_is_green() {
|
||||
assert_eq!(tier_colour("BASIC"), 0x53FC18);
|
||||
assert_eq!(tier_colour("anything else"), 0x53FC18);
|
||||
assert_eq!(tier_colour("mid"), 0x2E9BFF);
|
||||
assert_eq!(tier_colour("PREMIUM"), 0xB45CFF);
|
||||
assert_eq!(tier_colour("Legendary"), 0xFFA800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raid_with_nobody_in_it_says_only_that_it_came() {
|
||||
let none = serde_json::json!({ "host_username": "friend", "number_viewers": 0 });
|
||||
assert_eq!(describe_action("StreamHostEvent", &none).map(|(_, l)| l).as_deref(), Some("friend raided the channel"));
|
||||
let one = serde_json::json!({ "host_username": "friend", "number_viewers": 1 });
|
||||
assert_eq!(describe_action("StreamHostEvent", &one).map(|(_, l)| l).as_deref(), Some("friend raided with 1 viewers"));
|
||||
assert_eq!(action_card("StreamHostEvent", &none).unwrap().title, None, "no count to put on the card");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sender_is_drawn_in_their_colour_with_the_badges_that_say_something() {
|
||||
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), count: None };
|
||||
assert_eq!(style_of(None).color, None);
|
||||
let style = style_of(Some(&api::ChatIdentity {
|
||||
color: Some("#75FD46".into()),
|
||||
badges: vec![badge("moderator"), badge("level"), badge(""), badge("subscriber")],
|
||||
}));
|
||||
assert_eq!(style.color.as_deref(), Some("#75FD46"));
|
||||
// An engagement level and a nameless badge say nothing about the person.
|
||||
assert_eq!(style.badges.iter().map(|b| b.kind.as_str()).collect::<Vec<_>>(), vec!["moderator", "subscriber"]);
|
||||
// A colour that is empty is no colour.
|
||||
assert_eq!(style_of(Some(&api::ChatIdentity { color: Some(String::new()), badges: vec![] })).color, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_needs_both_who_and_what() {
|
||||
let whole = serde_json::json!({
|
||||
"original_sender": { "id": 1, "username": "Greg" },
|
||||
"original_message": { "id": "abc", "content": "hello" }
|
||||
});
|
||||
let preview = reply_preview_from_value(Some(&whole)).unwrap();
|
||||
assert_eq!((preview.id.as_str(), preview.from.as_str(), preview.body.as_str()), ("abc", "Greg", "hello"));
|
||||
// Half of one is a hole above the message, and is left out.
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_message": { "id": "abc", "content": "hello" } }))).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1, "username": "Greg" } }))).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1 }, "original_message": { "id": "abc" } }))).is_none());
|
||||
assert!(reply_preview_from_value(None).is_none());
|
||||
assert!(reply_preview_from_value(Some(&serde_json::json!("not metadata"))).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub mod pins;
|
||||
pub mod socket;
|
||||
pub mod watch;
|
||||
#[cfg(test)]
|
||||
mod testkit;
|
||||
pub(crate) mod testkit;
|
||||
pub mod api;
|
||||
|
||||
pub use cards::*;
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::*;
|
||||
/// real channel - they happen when a streamer decides to run one - so the
|
||||
/// only way to know this code works is to hand it the payload and look at
|
||||
/// what lands in the store. That is what these do.
|
||||
pub(super) fn simulated_daemon(name: &str) -> AppState {
|
||||
pub(crate) fn simulated_daemon(name: &str) -> AppState {
|
||||
// The same convention the audio and Tor probes use for a scratch
|
||||
// directory - a dependency for one test would be a poor trade.
|
||||
let dir = std::env::temp_dir().join(format!("nobilis-kick-{name}-{}", std::process::id()));
|
||||
|
||||
+70
@@ -313,4 +313,74 @@ mod tests {
|
||||
assert!(from_tenor_page(page, "https://tenor.com/view/x-gif-1").is_none());
|
||||
assert!(from_tenor_page("<html></html>", "https://tenor.com/view/x-gif-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_says_what_it_is_for() {
|
||||
let tenor = GifLink::Tenor { url: "https://tenor.com/view/x-gif-1".into() };
|
||||
let giphy = GifLink::Giphy { url: "https://giphy.com/gifs/x-abc".into(), id: "abc".into() };
|
||||
assert_eq!(tenor.url(), "https://tenor.com/view/x-gif-1");
|
||||
assert_eq!(giphy.url(), "https://giphy.com/gifs/x-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locale_is_skipped_only_when_it_is_one() {
|
||||
let url = |locale: &str| format!("https://tenor.com/{locale}/view/cat-gif-9");
|
||||
assert!(find_link(&url("de")).is_some());
|
||||
assert!(find_link(&url("en-GB")).is_some(), "five characters is the longest");
|
||||
assert!(find_link(&url("en-GBxx")).is_none(), "seven is not a locale");
|
||||
assert!(find_link("https://tenor.com/view/cat-gif-9").is_some(), "and none is fine");
|
||||
assert!(find_link("https://tenor.com/de/").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenor_page_is_read_from_its_own_tags() {
|
||||
let html = r#"<head><meta property="og:video" content="https://media.tenor.com/abc/cat.mp4?a=1&b=2">
|
||||
<meta property='og:video:width' content='480'><meta property="og:video:height" content="270"></head>"#;
|
||||
let e = from_tenor_page(html, "https://tenor.com/view/cat-gif-9").unwrap();
|
||||
assert_eq!(e.kind.as_deref(), Some("gif"));
|
||||
assert_eq!(e.url.as_deref(), Some("https://tenor.com/view/cat-gif-9"));
|
||||
assert_eq!(e.media_url.as_deref(), Some("https://media.tenor.com/abc/cat.mp4?a=1&b=2"));
|
||||
assert_eq!(e.media_kind.as_deref(), Some("video"));
|
||||
assert_eq!((e.width, e.height), (Some(480), Some(270)));
|
||||
assert_eq!(e.provider.as_deref(), Some("Tenor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenor_page_with_only_a_picture_gives_the_picture() {
|
||||
let html = r#"<meta property="og:image" content="https://media.tenor.com/abc/cat.gif"><meta property="og:image:width" content="1"><meta property="og:image:height" content="0">"#;
|
||||
let e = from_tenor_page(html, "https://tenor.com/view/cat-gif-9").unwrap();
|
||||
assert_eq!(e.media_kind.as_deref(), Some("image"));
|
||||
// A size of one is a size; a size of nothing is not one.
|
||||
assert_eq!((e.width, e.height), (Some(1), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_cannot_point_the_window_at_somewhere_else() {
|
||||
let from = |media: &str| from_tenor_page(&format!(r#"<meta property="og:video" content="{media}">"#), "https://tenor.com/view/x-gif-1");
|
||||
assert!(from("https://media.tenor.com/a.mp4").is_some());
|
||||
assert!(from("https://tenor.com/a.mp4").is_some());
|
||||
assert!(from("https://evil.example/a.mp4").is_none());
|
||||
assert!(from("https://notenor.com/a.mp4").is_none(), "a host that only ends the same way");
|
||||
assert!(from("http://media.tenor.com/a.mp4").is_none(), "and only over https");
|
||||
assert!(from_tenor_page("<html>nothing</html>", "https://tenor.com/view/x-gif-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_giphy_gif_is_made_of_its_id() {
|
||||
let e = from_giphy_id("https://giphy.com/gifs/dog-Z7wFi5b2VEAr6", "Z7wFi5b2VEAr6");
|
||||
assert_eq!(e.kind.as_deref(), Some("gif"));
|
||||
assert_eq!(e.url.as_deref(), Some("https://giphy.com/gifs/dog-Z7wFi5b2VEAr6"));
|
||||
assert_eq!(e.media_url.as_deref(), Some("https://media.giphy.com/media/Z7wFi5b2VEAr6/giphy.mp4"));
|
||||
assert_eq!(e.media_kind.as_deref(), Some("video"));
|
||||
assert_eq!(e.provider.as_deref(), Some("Giphy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_was_looked_at_is_remembered_until_the_table_is_full() {
|
||||
remember("https://tenor.com/view/remember-a", None);
|
||||
remember("https://tenor.com/view/remember-b", Some(Embed::default()));
|
||||
let known = KNOWN.lock().unwrap();
|
||||
assert!(known.contains_key("https://tenor.com/view/remember-a"), "a page that said nothing is remembered too");
|
||||
assert!(known.get("https://tenor.com/view/remember-b").is_some_and(|e| e.is_some()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,4 +180,23 @@ mod tests {
|
||||
assert_eq!(reopened.account("kick:me"), vec!["raid".to_string(), "gifted".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_accounts_words_go_with_it_and_nobody_elses_do() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-highlights-forget-{}", std::process::id()));
|
||||
let path = dir.join("highlights.toml");
|
||||
let store = HighlightStore::open(path.clone());
|
||||
store.set_global(vec!["everyone".into()]);
|
||||
store.set_account("irc:gone", vec!["mine".into()]);
|
||||
store.set_account("irc:kept", vec!["yours".into()]);
|
||||
store.forget("irc:gone");
|
||||
assert!(store.account("irc:gone").is_empty());
|
||||
assert_eq!(store.account("irc:kept"), vec!["yours".to_string()]);
|
||||
assert_eq!(store.global(), vec!["everyone".to_string()]);
|
||||
// And it is the file that forgot, not only this copy of it.
|
||||
let reopened = HighlightStore::open(path);
|
||||
assert!(reopened.account("irc:gone").is_empty());
|
||||
assert_eq!(reopened.account("irc:kept"), vec!["yours".to_string()]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,4 +194,20 @@ mod tests {
|
||||
assert!(store.for_account("irc:a").is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_accounts_list_goes_with_it_and_nobody_elses_does() {
|
||||
let dir = std::env::temp_dir().join(format!("moho-ignores-forget-{}", std::process::id()));
|
||||
let path = dir.join("ignores.toml");
|
||||
let store = IgnoreStore::open(path.clone());
|
||||
store.set("irc:gone", "Spammer", true);
|
||||
store.set("irc:kept", "Other", true);
|
||||
store.forget("irc:gone");
|
||||
assert!(store.for_account("irc:gone").is_empty());
|
||||
assert_eq!(store.for_account("irc:kept"), vec!["Other"]);
|
||||
let reopened = IgnoreStore::open(path);
|
||||
assert!(reopened.for_account("irc:gone").is_empty(), "the file forgot too");
|
||||
assert_eq!(reopened.for_account("irc:kept"), vec!["Other"]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
+104
-8
@@ -122,21 +122,36 @@ pub fn backlog_later(state: &AppState, buffer_id: &str, messages: &[crate::model
|
||||
if asked.len() > 4096 {
|
||||
asked.clear();
|
||||
}
|
||||
for message in messages.iter().rev().filter(|m| {
|
||||
m.embeds.is_empty()
|
||||
&& m.kind != "system"
|
||||
&& (youtube_id(&m.body).is_some() || crate::gifs::find_link(&m.body).is_some())
|
||||
}).take(BACKLOG_LIMIT) {
|
||||
let key = format!("{buffer_id}\u{0}{}", message.id);
|
||||
if asked.get(&key).is_some_and(|at| now.duration_since(*at) < ASK_AGAIN_AFTER) {
|
||||
for message in messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|m| wants_a_card(!m.embeds.is_empty(), &m.kind, &m.body))
|
||||
.take(BACKLOG_LIMIT)
|
||||
{
|
||||
if !first_asking(&mut asked, format!("{buffer_id}\u{0}{}", message.id), now) {
|
||||
continue;
|
||||
}
|
||||
asked.insert(key, now);
|
||||
youtube_later(state, &account_id, buffer_id, &message.id, &message.body);
|
||||
crate::gifs::gif_later(state, &account_id, buffer_id, &message.id, &message.body);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a stored message is one that could be given a card: it has none,
|
||||
/// somebody said it, and it carries a link that is described.
|
||||
fn wants_a_card(has_cards: bool, kind: &str, body: &str) -> bool {
|
||||
!has_cards && kind != "system" && (youtube_id(body).is_some() || crate::gifs::find_link(body).is_some())
|
||||
}
|
||||
|
||||
/// Whether this message has not been asked about lately - and if so, notes that
|
||||
/// it is being asked about now.
|
||||
fn first_asking(asked: &mut HashMap<String, std::time::Instant>, key: String, now: std::time::Instant) -> bool {
|
||||
if asked.get(&key).is_some_and(|at| now.duration_since(*at) < ASK_AGAIN_AFTER) {
|
||||
return false;
|
||||
}
|
||||
asked.insert(key, now);
|
||||
true
|
||||
}
|
||||
|
||||
/// Puts a card on a message beside whatever it already has.
|
||||
///
|
||||
/// More than one thing can describe a line - a YouTube link and a GIF in the
|
||||
@@ -385,4 +400,85 @@ mod tests {
|
||||
// A consent wall, or a removed video, has no title: no card.
|
||||
assert!(parse_page("<html><title> - YouTube</title></html>", watch).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_with_hyphens_and_underscores_are_whole_in_every_form() {
|
||||
// The ones that were lost: an id may start with either, and holds both.
|
||||
for url in [
|
||||
"https://www.youtube.com/watch?v=_-agl0pOQfs",
|
||||
"https://www.youtube.com/watch?v=_-agl0pOQfs&t=3",
|
||||
"https://www.youtube.com/watch?t=3&v=_-agl0pOQfs",
|
||||
"https://youtu.be/_-agl0pOQfs?si=abc",
|
||||
"https://www.youtube.com/shorts/_-agl0pOQfs",
|
||||
"https://www.youtube.com/embed/_-agl0pOQfs",
|
||||
] {
|
||||
assert_eq!(youtube_id(url).as_deref(), Some("_-agl0pOQfs"), "{url}");
|
||||
}
|
||||
assert_eq!(youtube_id("https://youtu.be/a-b_c-d_e-f").as_deref(), Some("a-b_c-d_e-f"));
|
||||
// Exactly eleven, not a character more.
|
||||
assert_eq!(youtube_id("https://youtu.be/aaaaaaaaaaaXYZ").as_deref(), Some("aaaaaaaaaaa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_v_that_is_not_in_the_link_is_not_its_video() {
|
||||
// After whitespace the link has ended, and after a # it is a fragment.
|
||||
assert_eq!(youtube_id("https://www.youtube.com/watch?x=1 and v=aaaaaaaaaaa"), None);
|
||||
assert_eq!(youtube_id("https://www.youtube.com/watch?x=1#v=aaaaaaaaaaa"), None);
|
||||
assert_eq!(youtube_id("https://www.youtube.com/watch?v=aaaaaaaaaaa#t=1").as_deref(), Some("aaaaaaaaaaa"));
|
||||
// And a character that is not part of an id ends it, short.
|
||||
assert_eq!(youtube_id("https://www.youtube.com/watch?v=aaaaa!aaaaa"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_kinds_of_card_are_youtube_red() {
|
||||
let watch = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
let card = parse(&serde_json::json!({ "title": "T" }), watch).unwrap();
|
||||
assert_eq!(card.color, Some(0xFF0000));
|
||||
let page = r#"{"videoPrimaryInfoRenderer":{"title":{"runs":[{"text":"T"}]}}}"#;
|
||||
assert_eq!(parse_page(page, watch).unwrap().color, Some(0xFF0000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_wants_a_card_when_it_has_none_and_a_link_that_is_described() {
|
||||
let yt = "look https://youtu.be/dQw4w9WgXcQ";
|
||||
assert!(wants_a_card(false, "chat", yt));
|
||||
assert!(wants_a_card(false, "chat", "https://tenor.com/view/cat-gif-123"));
|
||||
assert!(!wants_a_card(true, "chat", yt), "it already has a card");
|
||||
assert!(!wants_a_card(false, "system", yt), "nobody said it");
|
||||
assert!(!wants_a_card(false, "chat", "no links here"));
|
||||
assert!(!wants_a_card(false, "chat", "https://example.com/page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_is_asked_about_once_in_a_while() {
|
||||
let mut asked = HashMap::new();
|
||||
let t = std::time::Instant::now();
|
||||
assert!(first_asking(&mut asked, "a".into(), t), "never asked");
|
||||
assert!(!first_asking(&mut asked, "a".into(), t), "asked just now");
|
||||
assert!(!first_asking(&mut asked, "a".into(), t + ASK_AGAIN_AFTER - Duration::from_secs(1)), "still too soon");
|
||||
assert!(first_asking(&mut asked, "a".into(), t + ASK_AGAIN_AFTER), "long enough to try again");
|
||||
assert!(first_asking(&mut asked, "b".into(), t), "another message is its own question");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_is_added_beside_what_a_message_has_and_not_twice() {
|
||||
let state = crate::backend::kick::testkit::simulated_daemon("add-embed");
|
||||
state.runtime.record_message(
|
||||
&state, "kick:tester", "chan", "channel", "bob", "hello", false, "chat", None,
|
||||
Some("m1".into()), false, None, Vec::new(), Vec::new(), None,
|
||||
);
|
||||
let buffer = crate::model::buffer_id("kick:tester", "chan");
|
||||
let card = |url: &str| Embed { title: Some("T".into()), url: Some(url.into()), ..Default::default() };
|
||||
add_embed(&state, &buffer, "m1", card("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
|
||||
add_embed(&state, &buffer, "m1", card("https://tenor.com/view/x-gif-1"));
|
||||
// The same link again - from the other road it can arrive by.
|
||||
add_embed(&state, &buffer, "m1", card("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
|
||||
let stored = state.store.get_message(&buffer, "m1").unwrap().unwrap();
|
||||
let urls: Vec<_> = stored.embeds.iter().filter_map(|e| e.url.as_deref()).collect();
|
||||
assert_eq!(urls, vec!["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "https://tenor.com/view/x-gif-1"]);
|
||||
// A card with no address is never taken for a repeat of another with none.
|
||||
add_embed(&state, &buffer, "m1", Embed { title: Some("one".into()), ..Default::default() });
|
||||
add_embed(&state, &buffer, "m1", Embed { title: Some("two".into()), ..Default::default() });
|
||||
assert_eq!(state.store.get_message(&buffer, "m1").unwrap().unwrap().embeds.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user