use std::sync::{Arc, Mutex};
use serenity::all::{CreateActionRow, CreateEmbed, MessageId, UserId};
use shep_client::{
ReconnectingClient,
shep_core::{
protocol::{DogSource, Envelope, Lamb, ProcessInfo, Request, Response},
status::ProcStatus,
},
testing,
};
use tokio::sync::mpsc;
use crate::{
bot::channel::{Board, Posted, RECENT_MESSAGE_LIMIT},
error::Error,
limits::{
AUTOCOMPLETE_CHOICE_NAME_LIMIT, COMMAND_DESCRIPTION_LIMIT, COMMAND_NAME_LIMIT,
EMBED_TITLE_LIMIT, OPTION_DESCRIPTION_LIMIT, OPTION_NAME_LIMIT,
},
shepherd::Live,
};
#[track_caller]
pub fn assert_no_dashes(text: &str) {
assert!(!text.contains('\u{2014}'), "em dash in {text:?}");
assert!(!text.contains('\u{2013}'), "en dash in {text:?}");
}
#[track_caller]
pub fn assert_no_dashes_deep(value: &serde_json::Value) {
match value {
serde_json::Value::String(text) => assert_no_dashes(text),
serde_json::Value::Array(items) => items.iter().for_each(assert_no_dashes_deep),
serde_json::Value::Object(fields) => fields.values().for_each(assert_no_dashes_deep),
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {}
}
}
#[track_caller]
pub fn assert_registration_lengths(command: &serde_json::Value) {
within(command, "name", COMMAND_NAME_LIMIT);
within(command, "description", COMMAND_DESCRIPTION_LIMIT);
assert_option_lengths(command);
}
#[track_caller]
fn assert_option_lengths(node: &serde_json::Value) {
let Some(options) = node.get("options").and_then(serde_json::Value::as_array) else {
return;
};
for option in options {
within(option, "name", OPTION_NAME_LIMIT);
within(option, "description", OPTION_DESCRIPTION_LIMIT);
if let Some(choices) = option.get("choices").and_then(serde_json::Value::as_array) {
for choice in choices {
within(choice, "name", AUTOCOMPLETE_CHOICE_NAME_LIMIT);
}
}
assert_option_lengths(option);
}
}
#[track_caller]
fn within(node: &serde_json::Value, field: &str, limit: usize) {
let Some(text) = node.get(field).and_then(serde_json::Value::as_str) else {
return;
};
let length = text.chars().count();
assert!(
length <= limit,
"{field} is {length} characters, over Discord's {limit}: {text:?}. The whole \
registration payload is refused for one string over its cap, so this would take every \
command in this dog out of the guild at once."
);
}
pub const BOT_USER: u64 = 500;
#[derive(Default)]
pub struct CountingChannel {
state: Mutex<ChannelLog>,
}
#[derive(Default)]
struct ChannelLog {
sends: usize,
edits: usize,
deletes: usize,
next_id: u64,
recent: Vec<Posted>,
pages: usize,
posted: Vec<CreateEmbed>,
deleted: Vec<MessageId>,
}
impl CountingChannel {
pub fn new() -> Self {
Self::default()
}
pub fn preload(&self, messages: Vec<Posted>) {
self.state.lock().expect("not poisoned").recent = messages;
}
pub fn sends(&self) -> usize {
self.state.lock().expect("not poisoned").sends
}
pub fn edits(&self) -> usize {
self.state.lock().expect("not poisoned").edits
}
pub fn pages(&self) -> usize {
self.state.lock().expect("not poisoned").pages
}
pub fn deletes(&self) -> usize {
self.state.lock().expect("not poisoned").deletes
}
pub fn deleted(&self) -> Vec<MessageId> {
self.state.lock().expect("not poisoned").deleted.clone()
}
pub fn posted(&self) -> Vec<CreateEmbed> {
self.state.lock().expect("not poisoned").posted.clone()
}
}
impl Board for CountingChannel {
async fn me(&self) -> Result<UserId, Error> {
Ok(UserId::new(BOT_USER))
}
async fn post(
&self,
embed: CreateEmbed,
_buttons: CreateActionRow,
) -> Result<MessageId, Error> {
tokio::task::yield_now().await;
let mut state = self.state.lock().expect("not poisoned");
state.sends += 1;
state.next_id += 1;
state.posted.push(embed);
Ok(MessageId::new(state.next_id))
}
async fn edit(
&self,
_id: MessageId,
_embed: CreateEmbed,
_buttons: CreateActionRow,
) -> Result<(), Error> {
tokio::task::yield_now().await;
self.state.lock().expect("not poisoned").edits += 1;
Ok(())
}
async fn delete(&self, id: MessageId) -> Result<(), Error> {
tokio::task::yield_now().await;
let mut state = self.state.lock().expect("not poisoned");
state.deletes += 1;
state.deleted.push(id);
Ok(())
}
async fn recent(&self, before: Option<MessageId>) -> Result<Vec<Posted>, Error> {
tokio::task::yield_now().await;
let mut state = self.state.lock().expect("not poisoned");
state.pages += 1;
let start = match before {
None => 0,
Some(id) => state
.recent
.iter()
.position(|message| message.id == id)
.map_or(state.recent.len(), |at| at + 1),
};
Ok(state
.recent
.iter()
.skip(start)
.take(usize::from(RECENT_MESSAGE_LIMIT))
.cloned()
.collect())
}
}
pub struct Fake {
armed: Arc<Mutex<Option<(Request, Response)>>>,
#[allow(dead_code, reason = "kept only to outlive the fake shepherd")]
envelopes: mpsc::UnboundedReceiver<Envelope>,
#[allow(dead_code, reason = "kept only to outlive the fake shepherd")]
dir: tempfile::TempDir,
}
impl Fake {
pub fn expect(&mut self, request: Request) -> Armed<'_> {
Armed {
fake: self,
request,
}
}
}
pub struct Armed<'a> {
fake: &'a mut Fake,
request: Request,
}
impl Armed<'_> {
pub fn answer(self, response: Response) {
*self.fake.armed.lock().expect("not poisoned") = Some((self.request, response));
}
pub fn answer_wrong_shape(self, _reason: &str) {
self.answer(Response::Pong);
}
}
pub async fn test_live() -> (Live, Fake) {
let dir = tempfile::tempdir().expect("tempdir");
let socket = testing::control_address(dir.path());
let armed: Arc<Mutex<Option<(Request, Response)>>> = Arc::new(Mutex::new(None));
let script = Arc::clone(&armed);
let envelopes =
testing::fake_daemon_answering_with_ack(&socket, testing::sample_ack(), move |request| {
let (expected, response) =
script
.lock()
.expect("not poisoned")
.take()
.unwrap_or_else(|| {
panic!("the fake was asked {request:?} before a test armed one")
});
assert_eq!(
&expected, request,
"asked something other than what the test armed"
);
response
})
.await;
let client = ReconnectingClient::connect(&socket)
.await
.expect("test dial");
(
Live::new(client),
Fake {
armed,
envelopes,
dir,
},
)
}
pub fn sample(name: &str) -> ProcessInfo {
ProcessInfo::builder(0, name, ProcStatus::Online).build()
}
pub fn dog_sample(name: &str) -> ProcessInfo {
ProcessInfo::builder(0, name, ProcStatus::Online)
.dog(Some(DogSource::BuiltIn))
.build()
}
pub fn worst_case_sample(id: u32) -> ProcessInfo {
ProcessInfo::builder(id, "a".repeat(EMBED_TITLE_LIMIT + 50), ProcStatus::Online)
.pid(Some(u32::MAX))
.restarts(u32::MAX)
.uptime_ms(u64::MAX)
.cpu_percent(Some(f32::MIN))
.memory_bytes(Some(u64::MAX))
.instance(Some(u32::MAX))
.lambs(Some(vec![Lamb::new(u32::MAX, "x".repeat(2_000))]))
.fold(Some("f".repeat(2_000)))
.smit(Some("s".repeat(2_000)))
.dog(Some(DogSource::Adopted {
path: "p".repeat(2_000),
}))
.dog_stale(Some(true))
.build()
}