use crate::ChannelMessage;
use crate::channels::telegram::{ListenerFrame, is_control_message};
use crate::channels::telegram_intake::{BurstRecord, Intake};
use crate::util::UnwrapPoison;
use std::time::{Duration, SystemTime};
use tokio::sync::mpsc;
use tokio::time::Instant;
pub(super) const QUIET_WINDOW: Duration = Duration::from_secs(1);
const EXTENDED_WINDOW: Duration = Duration::from_secs(3);
const CEILING: Duration = Duration::from_secs(5);
const MAX_PARTS: usize = 6;
const MAX_CHARS: usize = 4000;
const PART_SEPARATOR: &str = "\n\n--- next message ---\n\n";
pub(crate) const QUEUE_CAPACITY: usize = 100;
#[derive(Debug)]
pub(crate) struct Preparations {
preparing: std::sync::Mutex<Option<(String, String)>>,
changed: tokio::sync::watch::Sender<()>,
}
impl Preparations {
pub(crate) fn new() -> Self {
Self {
preparing: std::sync::Mutex::new(None),
changed: tokio::sync::watch::channel(()).0,
}
}
pub(crate) fn watch(&self) -> tokio::sync::watch::Receiver<()> {
self.changed.subscribe()
}
pub(crate) fn begin(&self, user: &str, target: &str) -> Preparation<'_> {
let key = (user.to_string(), target.to_string());
let replaced = self.preparing.lock().unwrap_poison().replace(key.clone());
debug_assert!(
replaced.is_none(),
"two preparations at once: the listener prepares one message at a time"
);
self.bump();
Preparation {
preparations: self,
key,
}
}
fn busy(&self, user: &str, target: &str) -> bool {
self.preparing
.lock()
.unwrap_poison()
.as_ref()
.is_some_and(|(u, t)| u.as_str() == user && t.as_str() == target)
}
fn bump(&self) {
self.changed.send_modify(|()| {});
}
}
pub(crate) struct Preparation<'a> {
preparations: &'a Preparations,
key: (String, String),
}
impl Drop for Preparation<'_> {
fn drop(&mut self) {
let mut preparing = self.preparations.preparing.lock().unwrap_poison();
if preparing.as_ref() == Some(&self.key) {
*preparing = None;
}
drop(preparing);
self.preparations.bump();
}
}
struct Part {
message: ChannelMessage,
taken_at: SystemTime,
}
impl Part {
fn new(message: ChannelMessage) -> Self {
Self {
message,
taken_at: SystemTime::now(),
}
}
}
struct OpenGroup {
parts: Vec<Part>,
chars: usize,
first_at: Instant,
last_at: Instant,
held: bool,
held_until: Option<Instant>,
}
impl OpenGroup {
fn new(part: ChannelMessage, now: Instant) -> Self {
let chars = part.content.chars().count();
Self {
parts: vec![Part::new(part)],
chars,
first_at: now,
last_at: now,
held: false,
held_until: None,
}
}
fn key(&self) -> (&str, &str) {
let first = &self.parts[0].message;
(first.user_name.as_str(), first.reply_target.as_str())
}
fn matches(&self, part: &ChannelMessage) -> bool {
self.key() == (part.user_name.as_str(), part.reply_target.as_str())
}
fn add(&mut self, part: ChannelMessage, now: Instant) -> Option<Release> {
self.chars += part.content.chars().count();
self.last_at = now;
self.parts.push(Part::new(part));
if self.parts.len() >= MAX_PARTS {
Some(Release::PartCap)
} else if self.chars > MAX_CHARS {
Some(Release::CharCap)
} else {
None
}
}
fn extended(&self) -> bool {
self.parts.len() > 1 || self.parts.iter().any(|part| part.message.forwarded)
}
fn due(&self) -> (Instant, Release) {
let baseline = self
.held_until
.map_or(self.last_at, |ended| ended.max(self.last_at));
let extended = self.extended();
let windowed = baseline
+ if extended {
EXTENDED_WINDOW
} else {
QUIET_WINDOW
};
let ceiling = self.first_at + CEILING;
if self.held_until.is_some() || windowed <= ceiling {
(
windowed,
if extended {
Release::Extended
} else {
Release::Quiet
},
)
} else {
(ceiling, Release::Ceiling)
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Release {
Quiet,
Extended,
Ceiling,
PartCap,
CharCap,
Control,
Stop,
}
impl Release {
const fn as_str(self) -> &'static str {
match self {
Self::Quiet => "quiet_window",
Self::Extended => "extended_window",
Self::Ceiling => "ceiling",
Self::PartCap => "part_cap",
Self::CharCap => "char_cap",
Self::Control => "control",
Self::Stop => "stop",
}
}
}
struct Released {
parts: Vec<Part>,
release: Release,
}
impl Released {
fn into_message(self) -> ChannelMessage {
let parts: Vec<ChannelMessage> = self.parts.into_iter().map(|part| part.message).collect();
match <[ChannelMessage; 1]>::try_from(parts) {
Ok([only]) => only,
Err(parts) => group_message(parts),
}
}
fn record(&self, released_at: SystemTime) -> BurstRecord {
let newest = self
.parts
.last()
.expect("a released group holds at least one part");
let part_times: Vec<serde_json::Value> = self
.parts
.iter()
.map(|part| {
serde_json::json!({
"sent_at": part
.message
.update_date
.and_then(|secs| chrono::DateTime::from_timestamp(secs, 0))
.map(|at| at.to_rfc3339()),
"taken_at": chrono::DateTime::<chrono::Utc>::from(part.taken_at)
.to_rfc3339(),
})
})
.collect();
BurstRecord {
user_name: newest.message.user_name.clone(),
reply_target: newest.message.reply_target.clone(),
parts: self.parts.len(),
released_by: self.release.as_str(),
released_at: chrono::DateTime::<chrono::Utc>::from(released_at).to_rfc3339(),
part_times: serde_json::Value::Array(part_times).to_string(),
}
}
}
#[derive(Default)]
struct Collector {
open: Vec<OpenGroup>,
}
impl Collector {
fn push(&mut self, part: ChannelMessage, now: Instant) -> Option<Released> {
let Some(at) = self.open.iter().position(|group| group.matches(&part)) else {
self.open.push(OpenGroup::new(part, now));
return None;
};
let release = self.open[at].add(part, now)?;
Some(self.take_at(at, release))
}
fn observe(&mut self, preparations: &Preparations, now: Instant) {
for group in &mut self.open {
let (user, target) = group.key();
if preparations.busy(user, target) {
group.held = true;
} else if group.held {
group.held = false;
group.held_until = Some(now);
}
}
}
fn deadline(&self) -> Option<Instant> {
self.open
.iter()
.filter(|group| !group.held)
.map(|group| group.due().0)
.min()
}
fn release_due(&mut self, now: Instant) -> Vec<Released> {
self.take_where(|group| {
if group.held {
return None;
}
let (due_at, release) = group.due();
(due_at <= now).then_some(release)
})
}
fn release_chat(&mut self, user_name: &str, reply_target: &str) -> Vec<Released> {
self.take_where(|group| {
(group.key() == (user_name, reply_target)).then_some(Release::Control)
})
}
fn release_all(&mut self) -> Vec<Released> {
self.take_where(|_| Some(Release::Stop))
}
fn take_where(&mut self, pred: impl Fn(&OpenGroup) -> Option<Release>) -> Vec<Released> {
let mut ready = Vec::new();
let mut at = 0;
while at < self.open.len() {
if let Some(release) = pred(&self.open[at]) {
ready.push(self.take_at(at, release));
} else {
at += 1;
}
}
ready
}
fn take_at(&mut self, at: usize, release: Release) -> Released {
Released {
parts: self.open.remove(at).parts,
release,
}
}
}
fn group_message(mut parts: Vec<ChannelMessage>) -> ChannelMessage {
let newest = parts
.last()
.expect("a released group holds at least one part");
let user_name = newest.user_name.clone();
let reply_target = newest.reply_target.clone();
let channel = newest.channel.clone();
let update_ids = parts
.iter_mut()
.flat_map(|part| std::mem::take(&mut part.update_ids))
.collect();
ChannelMessage {
user_name,
reply_target,
channel,
parts,
update_ids,
..Default::default()
}
}
#[must_use]
pub fn compose_group_content(parts: Vec<ChannelMessage>) -> String {
match <[ChannelMessage; 1]>::try_from(parts) {
Ok([only]) => only.content,
Err(parts) => parts
.iter()
.map(|part| part.content.as_str())
.collect::<Vec<_>>()
.join(PART_SEPARATOR),
}
}
pub(crate) fn spawn_collector(
input: mpsc::Receiver<ChannelMessage>,
output: mpsc::Sender<ChannelMessage>,
intake: Intake,
preparations: std::sync::Arc<Preparations>,
frame: ListenerFrame,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(run_collector(input, output, intake, preparations, frame))
}
async fn run_collector(
mut input: mpsc::Receiver<ChannelMessage>,
output: mpsc::Sender<ChannelMessage>,
intake: Intake,
preparations: std::sync::Arc<Preparations>,
_frame: ListenerFrame,
) {
let mut collector = Collector::default();
let mut holds_changed = preparations.watch();
loop {
loop {
match input.try_recv() {
Ok(msg) => {
if !absorb(&mut collector, &output, &intake, msg).await {
return;
}
}
Err(mpsc::error::TryRecvError::Empty) => break,
Err(mpsc::error::TryRecvError::Disconnected) => {
hand_over_all(&output, &intake, collector.release_all()).await;
return;
}
}
}
let now = Instant::now();
collector.observe(&preparations, now);
if !hand_over_all(&output, &intake, collector.release_due(now)).await {
return;
}
tokio::select! {
() = wait_until(collector.deadline()) => {}
Ok(()) = holds_changed.changed() => {}
msg = input.recv() => {
let Some(msg) = msg else {
hand_over_all(&output, &intake, collector.release_all()).await;
return;
};
if !absorb(&mut collector, &output, &intake, msg).await {
return;
}
}
}
}
}
async fn absorb(
collector: &mut Collector,
output: &mpsc::Sender<ChannelMessage>,
intake: &Intake,
msg: ChannelMessage,
) -> bool {
if is_control_message(&msg) {
if !hand_over_all(
output,
intake,
collector.release_chat(&msg.user_name, &msg.reply_target),
)
.await
{
return false;
}
return send_to_pipeline(output, intake, msg).await;
}
if let Some(released) = collector.push(msg, Instant::now())
&& !hand_over(output, intake, released).await
{
return false;
}
true
}
async fn wait_until(deadline: Option<Instant>) {
match deadline {
Some(at) => tokio::time::sleep_until(at).await,
None => std::future::pending().await,
}
}
async fn hand_over_all(
output: &mpsc::Sender<ChannelMessage>,
intake: &Intake,
groups: Vec<Released>,
) -> bool {
for group in groups {
if !hand_over(output, intake, group).await {
return false;
}
}
true
}
async fn hand_over(
output: &mpsc::Sender<ChannelMessage>,
intake: &Intake,
released: Released,
) -> bool {
let record = released.record(SystemTime::now());
if released.parts.len() > 1 {
tracing::info!(
user_name = %record.user_name,
parts = record.parts,
released_by = record.released_by,
"Telegram burst: releasing collected messages as one request"
);
}
if !send_to_pipeline(output, intake, released.into_message()).await {
return false;
}
intake.record_burst(&record).await;
true
}
async fn send_to_pipeline(
output: &mpsc::Sender<ChannelMessage>,
intake: &Intake,
mut msg: ChannelMessage,
) -> bool {
let settled = std::mem::take(&mut msg.update_ids);
if output.send(msg).await.is_ok() {
intake.settle(&settled).await;
return true;
}
tracing::warn!(
"Telegram burst: the message pipeline is gone; the released group stays held in the intake's record"
);
false
}
#[cfg(test)]
mod tests {
use super::*;
fn part(user: &str, target: &str, content: &str) -> ChannelMessage {
ChannelMessage {
user_name: user.to_string(),
reply_target: target.to_string(),
channel: "telegram".to_string(),
content: content.to_string(),
..Default::default()
}
}
fn test_frame() -> ListenerFrame {
ListenerFrame::enter(
&std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
&std::sync::Arc::new(tokio::sync::Notify::new()),
)
}
fn no_preparations() -> std::sync::Arc<Preparations> {
std::sync::Arc::new(Preparations::new())
}
#[test]
fn lone_plain_part_releases_after_quiet_window() {
let mut collector = Collector::default();
let t0 = Instant::now();
assert!(collector.push(part("alice", "chat", "hello"), t0).is_none());
assert_eq!(collector.deadline(), Some(t0 + Duration::from_secs(1)));
assert!(
collector
.release_due(t0 + Duration::from_millis(999))
.is_empty()
);
let released = collector.release_due(t0 + Duration::from_secs(1));
assert_eq!(released.len(), 1);
assert_eq!(released[0].parts.len(), 1);
assert_eq!(released[0].parts[0].message.content, "hello");
assert_eq!(released[0].release, Release::Quiet);
}
#[test]
fn second_part_extends_the_window() {
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
collector.push(
part("alice", "chat", "two"),
t0 + Duration::from_millis(500),
);
assert_eq!(
collector.deadline(),
Some(t0 + Duration::from_millis(500) + EXTENDED_WINDOW)
);
let mut forwarded = Collector::default();
let mut forwarded_part = part("alice", "chat", "hi");
forwarded_part.forwarded = true;
forwarded.push(forwarded_part, t0);
assert_eq!(forwarded.deadline(), Some(t0 + EXTENDED_WINDOW));
}
#[test]
fn deadline_never_exceeds_the_ceiling() {
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
collector.push(part("alice", "chat", "two"), t0 + Duration::from_secs(2));
collector.push(part("alice", "chat", "three"), t0 + Duration::from_secs(4));
assert_eq!(collector.deadline(), Some(t0 + CEILING));
}
#[test]
fn part_cap_releases_the_group() {
let mut collector = Collector::default();
let t0 = Instant::now();
for _ in 0..MAX_PARTS - 1 {
assert!(collector.push(part("alice", "chat", "x"), t0).is_none());
}
let released = collector
.push(part("alice", "chat", "x"), t0)
.expect("reaching the part cap must release");
assert_eq!(released.parts.len(), MAX_PARTS);
assert_eq!(released.release, Release::PartCap);
}
#[test]
fn character_cap_releases_the_group() {
let mut collector = Collector::default();
let t0 = Instant::now();
let half = "x".repeat(MAX_CHARS / 2 + 1);
assert!(collector.push(part("alice", "chat", &half), t0).is_none());
let released = collector
.push(part("alice", "chat", &half), t0)
.expect("exceeding the character cap must release");
assert_eq!(released.parts.len(), 2);
assert_eq!(released.release, Release::CharCap);
let half = "x".repeat(MAX_CHARS / 2);
let mut at_cap = Collector::default();
at_cap.push(part("alice", "chat", &half), t0);
assert!(at_cap.push(part("alice", "chat", &half), t0).is_none());
assert!(at_cap.deadline().is_some());
}
#[test]
fn groups_are_keyed_by_user_and_chat() {
let t0 = Instant::now();
let mut collector = Collector::default();
collector.push(part("alice", "chat-a", "a1"), t0);
collector.push(part("bob", "chat-a", "b1"), t0);
collector.push(part("alice", "chat-b", "a2"), t0);
collector.push(part("alice", "chat-a", "a3"), t0);
let released = collector.release_chat("alice", "chat-a");
assert_eq!(released.len(), 1);
assert_eq!(released[0].parts.len(), 2);
assert_eq!(released[0].parts[0].message.content, "a1");
assert_eq!(released[0].parts[1].message.content, "a3");
assert_eq!(released[0].release, Release::Control);
assert_eq!(collector.release_all().len(), 2);
assert!(collector.release_all().is_empty());
assert_eq!(collector.deadline(), None);
}
#[test]
fn compose_group_content_joins_parts_in_order() {
let parts = vec![part("alice", "chat", "one"), part("alice", "chat", "two")];
assert_eq!(
compose_group_content(parts),
format!("one{PART_SEPARATOR}two")
);
let single = vec![part("alice", "chat", "solo")];
assert_eq!(compose_group_content(single), "solo");
}
async fn released(rx: &mut mpsc::Receiver<ChannelMessage>) -> ChannelMessage {
tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.expect("the collector released nothing")
.expect("the collector stopped early")
}
async fn test_intake() -> (
Intake,
crate::channels::telegram_intake::TelegramIntakeStore,
tempfile::TempDir,
) {
let (store, dir) = crate::open_test_store!(
crate::channels::telegram_intake::TelegramIntakeStore,
"telegram_intake"
);
let intake = Intake::new(store.clone(), std::sync::Arc::from("run-test"), "42");
(intake, store, dir)
}
#[tokio::test]
async fn collector_hands_a_command_over_after_the_open_group() {
let (input, input_rx) = mpsc::channel(QUEUE_CAPACITY);
let (output, mut output_rx) = mpsc::channel(8);
let (intake, _store, _dir) = test_intake().await;
spawn_collector(input_rx, output, intake, no_preparations(), test_frame());
input.send(part("alice", "chat", "one")).await.unwrap();
input.send(part("alice", "chat", "two")).await.unwrap();
input.send(part("alice", "chat", "/clear")).await.unwrap();
let group = released(&mut output_rx).await;
assert_eq!(group.parts.len(), 2);
assert!(group.content.is_empty());
let command = released(&mut output_rx).await;
assert_eq!(command.content, "/clear");
}
#[tokio::test]
async fn collector_releases_the_intact_group_when_the_listener_goes_away() {
let (intake, store, _dir) = test_intake().await;
let (input, input_rx) = mpsc::channel(QUEUE_CAPACITY);
let (output, mut output_rx) = mpsc::channel(8);
spawn_collector(
input_rx,
output,
intake.clone(),
no_preparations(),
test_frame(),
);
let mut first = part("alice", "chat", "[Forwarded from @bob] one");
first.forwarded = true;
first.attachment_dirs = vec!["dir-one".to_string()];
first.update_ids = vec![11];
let mut second = part("alice", "chat", "two");
second.attachment_dirs = vec!["dir-two".to_string()];
second.update_ids = vec![12];
for id in [11, 12] {
intake
.hold(&[&serde_json::json!({ "update_id": id })])
.await
.expect("hold");
}
input.send(first).await.unwrap();
input.send(second).await.unwrap();
drop(input);
let group = released(&mut output_rx).await;
assert_eq!(group.parts.len(), 2);
assert_eq!(group.parts[0].content, "[Forwarded from @bob] one");
assert!(
group.parts[0].forwarded,
"the forwarded fact must ride through the collector, not just its label"
);
assert_eq!(group.parts[0].attachment_dirs, vec!["dir-one".to_string()]);
assert_eq!(group.parts[1].content, "two");
assert_eq!(group.parts[1].attachment_dirs, vec!["dir-two".to_string()]);
assert!(group.content.is_empty());
assert_eq!(group.user_name, "alice");
assert_eq!(group.reply_target, "chat");
assert_eq!(group.channel, "telegram");
assert!(group.parts[0].update_ids.is_empty());
assert!(group.parts[1].update_ids.is_empty());
assert!(group.update_ids.is_empty());
assert_eq!(
compose_group_content(group.parts.clone()),
format!("[Forwarded from @bob] one{PART_SEPARATOR}two")
);
assert!(output_rx.recv().await.is_none());
assert!(
Intake::new(store.clone(), std::sync::Arc::from("later-run"), "42")
.take_over()
.await
.expect("take over")
.is_empty(),
"the hand-over must settle the events so no later run takes them up"
);
}
#[test]
fn a_group_is_held_while_its_sender_is_still_being_prepared() {
let preparations = no_preparations();
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
let guard = preparations.begin("alice", "chat");
collector.observe(&preparations, t0);
assert!(
collector.deadline().is_none(),
"a group whose pair is being prepared must have no deadline"
);
assert!(
collector
.release_due(t0 + Duration::from_secs(30))
.is_empty(),
"however long its own window has run, a held group must not be released"
);
drop(guard);
collector.observe(&preparations, t0 + Duration::from_secs(2));
collector.push(part("alice", "chat", "two"), t0 + Duration::from_secs(2));
assert_eq!(
collector.deadline(),
Some(t0 + Duration::from_secs(2) + EXTENDED_WINDOW)
);
let released = collector.release_due(t0 + Duration::from_secs(2) + EXTENDED_WINDOW);
assert_eq!(released.len(), 1);
assert_eq!(released[0].parts.len(), 2);
assert_eq!(released[0].release, Release::Extended);
}
#[test]
fn a_preparation_holds_only_the_pair_it_belongs_to() {
let preparations = no_preparations();
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat-a", "a1"), t0);
collector.push(part("bob", "chat-a", "b1"), t0);
let _guard = preparations.begin("bob", "chat-a");
collector.observe(&preparations, t0);
assert_eq!(collector.deadline(), Some(t0 + QUIET_WINDOW));
let released = collector.release_due(t0 + QUIET_WINDOW);
assert_eq!(released.len(), 1);
assert_eq!(released[0].parts[0].message.user_name, "alice");
assert!(collector.deadline().is_none());
assert!(
collector
.release_due(t0 + Duration::from_secs(30))
.is_empty()
);
}
#[test]
fn the_ceiling_waits_with_a_held_group() {
let preparations = no_preparations();
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
let guard = preparations.begin("alice", "chat");
collector.observe(&preparations, t0);
drop(guard);
let ended = t0 + CEILING + Duration::from_secs(1);
collector.observe(&preparations, ended);
assert_eq!(collector.deadline(), Some(ended + QUIET_WINDOW));
}
#[test]
fn a_cleared_preparation_does_not_release_the_group_it_held() {
let preparations = no_preparations();
let mut collector = Collector::default();
let t0 = Instant::now();
collector.push(part("alice", "chat", "one"), t0);
let guard = preparations.begin("alice", "chat");
collector.observe(&preparations, t0);
drop(guard);
let cleared = t0 + Duration::from_secs(2);
collector.observe(&preparations, cleared);
assert!(
collector.release_due(cleared).is_empty(),
"the group must not be released before the part the preparation produced arrives"
);
assert_eq!(collector.deadline(), Some(cleared + QUIET_WINDOW));
collector.push(part("alice", "chat", "two"), cleared);
let released = collector.release_due(cleared + EXTENDED_WINDOW);
assert_eq!(released.len(), 1);
assert_eq!(released[0].parts.len(), 2);
assert_eq!(released[0].release, Release::Extended);
}
#[test]
fn the_record_names_each_part_and_what_released_it() {
let mut collector = Collector::default();
let t0 = Instant::now();
let mut first = part("alice", "chat", "one");
first.update_date = Some(1_700_000_000);
let mut second = part("alice", "chat", "two");
second.update_date = None;
let pushed_at = SystemTime::now();
collector.push(first, t0);
collector.push(second, t0 + Duration::from_secs(1));
let at = t0 + Duration::from_secs(1) + EXTENDED_WINDOW;
let released = collector.release_due(at);
assert_eq!(released.len(), 1);
assert_eq!(released[0].release, Release::Extended);
let released_at = SystemTime::now();
let record = released[0].record(released_at);
assert_eq!(record.parts, 2);
assert_eq!(record.released_by, "extended_window");
assert_eq!(record.user_name, "alice");
assert_eq!(record.reply_target, "chat");
let part_times: Vec<serde_json::Value> =
serde_json::from_str(&record.part_times).expect("part_times is JSON");
assert_eq!(part_times.len(), 2, "one entry per part");
assert_eq!(
part_times[0]["sent_at"].as_str(),
Some(
chrono::DateTime::from_timestamp(1_700_000_000, 0)
.expect("a valid timestamp")
.to_rfc3339()
.as_str()
)
);
assert!(part_times[1]["sent_at"].is_null());
let taken_at = |at: usize| {
chrono::DateTime::parse_from_rfc3339(
part_times[at]["taken_at"]
.as_str()
.expect("taken_at is set"),
)
.expect("taken_at is RFC 3339")
};
let (pushed_at, released_at) = (
chrono::DateTime::<chrono::Utc>::from(pushed_at),
chrono::DateTime::<chrono::Utc>::from(released_at),
);
for at in 0..part_times.len() {
assert!(
taken_at(at) >= pushed_at && taken_at(at) <= released_at,
"part {at} was not recorded at its own moment"
);
}
assert!(
taken_at(0) <= taken_at(1),
"the parts must be recorded in send order"
);
}
#[test]
fn every_release_rule_is_named_in_the_record() {
let rules = [
(Release::Quiet, "quiet_window"),
(Release::Extended, "extended_window"),
(Release::Ceiling, "ceiling"),
(Release::PartCap, "part_cap"),
(Release::CharCap, "char_cap"),
(Release::Control, "control"),
(Release::Stop, "stop"),
];
for (rule, named) in rules {
let released = Released {
parts: vec![Part::new(part("alice", "chat", "one"))],
release: rule,
};
assert_eq!(
released.record(SystemTime::now()).released_by,
named,
"{rule:?} must be recorded as {named}"
);
}
}
}