From 25b044a7df89df6af0706efc29874faa84344f71 Mon Sep 17 00:00:00 2001 From: Dan Nixon Date: Tue, 30 Apr 2024 21:24:53 +0100 Subject: [PATCH] Initial announcer implementation --- Cargo.lock | 44 +++- Cargo.toml | 2 + client/Cargo.toml | 7 +- client/examples/announcer.rs | 26 ++ client/examples/now_and_next.rs | 3 +- client/examples/schedule.rs | 3 +- client/examples/venues.rs | 3 +- client/src/announcer/mod.rs | 184 +++++++++++++ client/src/announcer/test.rs | 241 ++++++++++++++++++ client/src/announcer/utils.rs | 33 +++ client/src/client.rs | 3 +- client/src/error.rs | 7 + client/src/lib.rs | 10 +- client/src/schedule/event/mod.rs | 36 ++- client/src/schedule/mod.rs | 2 +- client/src/schedule/mutation/at_venues.rs | 3 + client/src/schedule/mutation/ends_after.rs | 15 +- .../src/schedule/mutation/fake_start_epoch.rs | 15 +- .../schedule/mutation/sorted_by_start_time.rs | 15 +- client/src/schedule/mutation/starts_after.rs | 15 +- client/src/schedule/now_and_next.rs | 14 + client/src/testing.rs | 94 +++++++ 22 files changed, 738 insertions(+), 37 deletions(-) create mode 100644 client/examples/announcer.rs create mode 100644 client/src/announcer/mod.rs create mode 100644 client/src/announcer/test.rs create mode 100644 client/src/announcer/utils.rs create mode 100644 client/src/error.rs create mode 100644 client/src/testing.rs diff --git a/Cargo.lock b/Cargo.lock index 5c29538..8d27cca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -421,17 +421,53 @@ dependencies = [ "serde", ] +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "emfcamp-schedule-api" version = "0.0.1" dependencies = [ "anyhow", + "axum", "chrono", + "derive_builder", "reqwest", "serde", "serde_json", "serde_with", + "thiserror", "tokio", + "tracing", + "tracing-subscriber", "url", ] @@ -1020,18 +1056,18 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 56b23f3..9519d3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ axum-extra = { version = "0.9.3", features = ["query"] } chrono = { version = "0.4.38", features = ["serde"] } clap = { version = "~4.4.0", features = ["derive", "env"] } clap_complete = "~4.4.10" +derive_builder = "0.20.0" emfcamp-schedule-api = { path = "./client/" } metrics = "0.22.3" metrics-exporter-prometheus = { version = "0.14.0", default-features = false, features = ["http-listener"] } @@ -28,6 +29,7 @@ serde = { version = "1.0.200", features = ["derive"] } serde_json = "1.0.116" serde_with = "3.8.1" termcolor = "1.4.1" +thiserror = "1.0.59" tokio = { version = "1.37.0", features = ["rt-multi-thread", "macros"] } tracing = "0.1.40" tracing-subscriber = "0.3.18" diff --git a/client/Cargo.toml b/client/Cargo.toml index e59f7cf..ceaeafb 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -6,12 +6,17 @@ edition.workspace = true [dependencies] chrono.workspace = true +derive_builder.workspace = true reqwest.workspace = true serde.workspace = true serde_json.workspace = true serde_with.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true url.workspace = true [dev-dependencies] anyhow.workspace = true -tokio.workspace = true +axum.workspace = true +tracing-subscriber.workspace = true diff --git a/client/examples/announcer.rs b/client/examples/announcer.rs new file mode 100644 index 0000000..2d6273b --- /dev/null +++ b/client/examples/announcer.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use emfcamp_schedule_api::{ + announcer::{Announcer, AnnouncerSettingsBuilder}, + Client, +}; +use url::Url; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let url = Url::parse("https://www.emfcamp.org/schedule/2024.json")?; + + let client = Client::new(url); + + let settings = AnnouncerSettingsBuilder::default() + .schedule_refresh(tokio::time::Duration::from_secs(5)) + .build()?; + + let mut announcer = Announcer::new(settings, client).await?; + + loop { + let event = announcer.poll().await; + println!("{:?}", event); + } +} diff --git a/client/examples/now_and_next.rs b/client/examples/now_and_next.rs index 8af4c9b..f5f01d4 100644 --- a/client/examples/now_and_next.rs +++ b/client/examples/now_and_next.rs @@ -1,12 +1,13 @@ use anyhow::Result; use chrono::Local; +use emfcamp_schedule_api::Client; use url::Url; #[tokio::main] async fn main() -> Result<()> { let url = Url::parse("https://www.emfcamp.org/schedule/2024.json")?; - let client = emfcamp_schedule_api::Client::new(url); + let client = Client::new(url); let schedule = client.get_schedule().await?; diff --git a/client/examples/schedule.rs b/client/examples/schedule.rs index 675eea2..b9ecf2b 100644 --- a/client/examples/schedule.rs +++ b/client/examples/schedule.rs @@ -1,11 +1,12 @@ use anyhow::Result; +use emfcamp_schedule_api::Client; use url::Url; #[tokio::main] async fn main() -> Result<()> { let url = Url::parse("https://www.emfcamp.org/schedule/2024.json")?; - let client = emfcamp_schedule_api::Client::new(url); + let client = Client::new(url); let schedule = client.get_schedule().await?; diff --git a/client/examples/venues.rs b/client/examples/venues.rs index 0f54b0e..ffaf38f 100644 --- a/client/examples/venues.rs +++ b/client/examples/venues.rs @@ -1,11 +1,12 @@ use anyhow::Result; +use emfcamp_schedule_api::Client; use url::Url; #[tokio::main] async fn main() -> Result<()> { let url = Url::parse("https://www.emfcamp.org/schedule/2024.json")?; - let client = emfcamp_schedule_api::Client::new(url); + let client = Client::new(url); let schedule = client.get_schedule().await?; diff --git a/client/src/announcer/mod.rs b/client/src/announcer/mod.rs new file mode 100644 index 0000000..df39f45 --- /dev/null +++ b/client/src/announcer/mod.rs @@ -0,0 +1,184 @@ +#[cfg(test)] +mod test; +mod utils; + +use crate::{ + schedule::{event::Event, Schedule}, + Client, +}; +use chrono::{DateTime, Duration as ChronoDuration, FixedOffset, Utc}; +use derive_builder::Builder; +use tokio::time::{Duration as TokioDuration, Instant}; +use tracing::{debug, info, warn}; + +#[derive(Debug, Builder)] +#[builder(default)] +pub struct AnnouncerSettings { + schedule_refresh: TokioDuration, + event_start_offset: ChronoDuration, +} + +impl Default for AnnouncerSettings { + fn default() -> Self { + Self { + schedule_refresh: TokioDuration::from_secs(60), + event_start_offset: ChronoDuration::zero(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum AnnouncerScheduleChanges { + Changes, + NoChanges, +} + +#[allow(clippy::large_enum_variant)] +#[derive(Debug, PartialEq, Eq)] +pub enum AnnouncerPollResult { + Event(Event), + NoMoreEvents, + ScheduleRefreshed(AnnouncerScheduleChanges), +} + +/// A subset of fields from the last event that was notified. +/// Used to select the next event to be notified. +struct LastNotifiedEventMarker { + start: DateTime, + id: u32, +} + +impl LastNotifiedEventMarker { + fn matches(&self, event: &Event) -> bool { + self.start == event.start && self.id == event.id + } +} + +impl From<&Event> for LastNotifiedEventMarker { + fn from(value: &Event) -> Self { + Self { + start: value.start, + id: value.id, + } + } +} + +pub struct Announcer { + settings: AnnouncerSettings, + + client: Client, + + schedule: Schedule, + schedule_timestamp: Instant, + + last_notified_event_marker: Option, +} + +impl Announcer { + pub async fn new(settings: AnnouncerSettings, client: Client) -> crate::Result { + let (schedule, schedule_timestamp) = self::utils::get_sorted_schedule(&client).await?; + + Ok(Self { + settings, + client, + schedule, + schedule_timestamp, + last_notified_event_marker: None, + }) + } + + async fn update_schedule(&mut self) -> crate::Result { + let (schedule, schedule_timestamp) = self::utils::get_sorted_schedule(&self.client).await?; + + let changes = if self.schedule == schedule { + debug!("No changes in new schedule"); + AnnouncerScheduleChanges::NoChanges + } else { + info!("New schedule is different from previously loaded"); + AnnouncerScheduleChanges::Changes + }; + + self.schedule = schedule; + self.schedule_timestamp = schedule_timestamp; + + Ok(changes) + } + + pub async fn poll(&mut self) -> crate::Result { + loop { + // Calculate when the next schedule refresh is due + let next_schedule_update_time = + self.schedule_timestamp + self.settings.schedule_refresh; + + // Determine what the next event to announce is and in how much time it is due to be announced + let next_event = self.get_next_event_to_announce(); + let event_wait_time = match next_event { + Some(ref event) => self::utils::get_duration_before_event( + Utc::now().into(), + self.settings.event_start_offset, + event, + ), + None => TokioDuration::from_secs(60), + }; + debug!("Time to wait before next event: {:?}", event_wait_time); + + // Wait for one of several things to happen... + tokio::select! { + // 1. The schedule is refreshed at the requested interval + _ = tokio::time::sleep_until(next_schedule_update_time) => { + match self.update_schedule().await { + Ok(changes) => { + return Ok(AnnouncerPollResult::ScheduleRefreshed(changes)) + }, + Err(e) => { + warn!("Failed to update schedule {e}") + }, + } + } + // 2. The next event to be announced needs to be announced + _ = tokio::time::sleep(event_wait_time) => { + if let Some(event) = next_event { + self.update_event_marker(&event); + return Ok(AnnouncerPollResult::Event(event)) + } + } + } + } + } + + fn get_next_event_to_announce(&self) -> Option { + match &self.last_notified_event_marker { + Some(marker) => { + match self + .schedule + .events + .iter() + .position(|event| marker.matches(event)) + { + Some(idx) => { + debug!("Matched last notified event marker, picking next in schedule as next to announce"); + self.schedule.events.get(idx + 1).cloned() + } + None => { + debug!("Last notified event marker matched no events (something's fucky...), picking next chronological event as next to announce"); + self.schedule + .events + .iter() + .find(|event| event.start > marker.start) + .cloned() + } + } + } + None => { + debug!( + "No last notified event marker, picking first in schedule as next to announce" + ); + self.schedule.events.first().cloned() + } + } + } + + fn update_event_marker(&mut self, event: &Event) { + self.last_notified_event_marker = Some(event.into()); + } +} diff --git a/client/src/announcer/test.rs b/client/src/announcer/test.rs new file mode 100644 index 0000000..cd373dd --- /dev/null +++ b/client/src/announcer/test.rs @@ -0,0 +1,241 @@ +use super::*; +use crate::testing::DummyScheduleServer; +use serde_json::Value; +use std::collections::HashMap; +use tokio::time::{Duration, Instant}; + +fn fixup_events_for_test_comparison(events: &mut [Event]) { + for event in events { + event.extra = HashMap::from([("type".to_string(), Value::String("talk".to_string()))]); + } +} + +#[tokio::test] +async fn t2_schedule_is_refreshed_on_requested_schedule() { + let mut dummy_server = DummyScheduleServer::new(8002).await; + + let now = Utc::now(); + dummy_server.set_events(vec![Event::dummy( + 0, + (now + ChronoDuration::try_minutes(1).unwrap()).into(), + )]); + + let client = Client::new(dummy_server.url()); + + let now_i = Instant::now(); + let mut announcer = Announcer::new( + AnnouncerSettingsBuilder::default() + .schedule_refresh(Duration::from_secs(3)) + .build() + .unwrap(), + client, + ) + .await + .unwrap(); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(3), + AnnouncerPollResult::ScheduleRefreshed(AnnouncerScheduleChanges::NoChanges) + ); + + dummy_server.stop().await; +} + +#[tokio::test] +async fn t3_changes_to_the_schedule_are_noticed() { + let mut dummy_server = DummyScheduleServer::new(8003).await; + + let now = Utc::now(); + dummy_server.set_events(vec![Event::dummy( + 0, + (now + ChronoDuration::try_minutes(1).unwrap()).into(), + )]); + + let client = Client::new(dummy_server.url()); + + let now_i = Instant::now(); + let mut announcer = Announcer::new( + AnnouncerSettingsBuilder::default() + .schedule_refresh(Duration::from_secs(3)) + .build() + .unwrap(), + client, + ) + .await + .unwrap(); + + dummy_server.set_events(vec![Event::dummy( + 1, + (now + ChronoDuration::try_minutes(1).unwrap()).into(), + )]); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(3), + AnnouncerPollResult::ScheduleRefreshed(AnnouncerScheduleChanges::Changes) + ); + + dummy_server.stop().await; +} + +#[tokio::test] +async fn t4_basic_event_notification() { + let mut dummy_server = DummyScheduleServer::new(8004).await; + + let now = Utc::now(); + let mut events = vec![ + Event::dummy(0, (now + ChronoDuration::try_seconds(1).unwrap()).into()), + Event::dummy(1, (now + ChronoDuration::try_seconds(2).unwrap()).into()), + Event::dummy(2, (now + ChronoDuration::try_seconds(3).unwrap()).into()), + ]; + + dummy_server.set_events(events.clone()); + + fixup_events_for_test_comparison(&mut events); + + let client = Client::new(dummy_server.url()); + + let now_i = Instant::now(); + let mut announcer = Announcer::new( + AnnouncerSettingsBuilder::default() + .schedule_refresh(Duration::from_secs(600)) + .event_start_offset(ChronoDuration::zero()) + .build() + .unwrap(), + client, + ) + .await + .unwrap(); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(1), + AnnouncerPollResult::Event(events[0].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(2), + AnnouncerPollResult::Event(events[1].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(3), + AnnouncerPollResult::Event(events[2].clone()) + ); + + dummy_server.stop().await; +} + +#[tokio::test] +async fn t5_event_notification_with_multiple_identical_start_times() { + let mut dummy_server = DummyScheduleServer::new(8005).await; + + let now = Utc::now(); + let mut events = vec![ + Event::dummy(0, (now + ChronoDuration::try_seconds(1).unwrap()).into()), + Event::dummy(1, (now + ChronoDuration::try_seconds(2).unwrap()).into()), + Event::dummy(2, (now + ChronoDuration::try_seconds(2).unwrap()).into()), + Event::dummy(3, (now + ChronoDuration::try_seconds(3).unwrap()).into()), + ]; + + dummy_server.set_events(events.clone()); + + fixup_events_for_test_comparison(&mut events); + + let client = Client::new(dummy_server.url()); + + let now_i = Instant::now(); + let mut announcer = Announcer::new( + AnnouncerSettingsBuilder::default() + .schedule_refresh(Duration::from_secs(600)) + .event_start_offset(ChronoDuration::zero()) + .build() + .unwrap(), + client, + ) + .await + .unwrap(); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(1), + AnnouncerPollResult::Event(events[0].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(2), + AnnouncerPollResult::Event(events[1].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(2), + AnnouncerPollResult::Event(events[2].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(3), + AnnouncerPollResult::Event(events[3].clone()) + ); + + dummy_server.stop().await; +} + +#[tokio::test] +async fn t6_basic_event_notification_unsorted() { + let mut dummy_server = DummyScheduleServer::new(8006).await; + + let now = Utc::now(); + let mut events = vec![ + Event::dummy(0, (now + ChronoDuration::try_seconds(1).unwrap()).into()), + Event::dummy(1, (now + ChronoDuration::try_seconds(2).unwrap()).into()), + Event::dummy(2, (now + ChronoDuration::try_seconds(3).unwrap()).into()), + ]; + + dummy_server.set_events(vec![ + events[1].clone(), + events[0].clone(), + events[2].clone(), + ]); + + fixup_events_for_test_comparison(&mut events); + + let client = Client::new(dummy_server.url()); + + let now_i = Instant::now(); + let mut announcer = Announcer::new( + AnnouncerSettingsBuilder::default() + .schedule_refresh(Duration::from_secs(600)) + .event_start_offset(ChronoDuration::zero()) + .build() + .unwrap(), + client, + ) + .await + .unwrap(); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(1), + AnnouncerPollResult::Event(events[0].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(2), + AnnouncerPollResult::Event(events[1].clone()) + ); + + crate::assert_future_in!( + announcer.poll(), + now_i + Duration::from_secs(3), + AnnouncerPollResult::Event(events[2].clone()) + ); + + dummy_server.stop().await; +} diff --git a/client/src/announcer/utils.rs b/client/src/announcer/utils.rs new file mode 100644 index 0000000..c7c0b12 --- /dev/null +++ b/client/src/announcer/utils.rs @@ -0,0 +1,33 @@ +use crate::{ + schedule::{ + event::Event, + mutation::{Mutators, SortedByStartTime}, + Schedule, + }, + Client, +}; +use chrono::{DateTime, Duration as ChronoDuration, FixedOffset}; +use tokio::time::{Duration as TokioDuration, Instant}; +use tracing::warn; + +pub(super) async fn get_sorted_schedule(client: &Client) -> crate::Result<(Schedule, Instant)> { + let mut schedule = client.get_schedule().await?; + schedule.mutate(&Mutators::new_single(Box::new(SortedByStartTime {}))); + + let now = Instant::now(); + + Ok((schedule, now)) +} + +pub(super) fn get_duration_before_event( + timepoint: DateTime, + start_offset: ChronoDuration, + event: &Event, +) -> TokioDuration { + let delta = (event.start + start_offset) - timepoint; + + delta.to_std().unwrap_or_else(|e| { + warn!("Negative time before event, something may be fucky... ({e})"); + std::time::Duration::ZERO + }) +} diff --git a/client/src/client.rs b/client/src/client.rs index 542a733..8c8067c 100644 --- a/client/src/client.rs +++ b/client/src/client.rs @@ -1,5 +1,4 @@ use crate::schedule::{event::Event, Schedule}; -use reqwest::Error; use url::Url; #[derive(Debug, Clone)] @@ -12,7 +11,7 @@ impl Client { Self { url } } - pub async fn get_schedule(&self) -> Result { + pub async fn get_schedule(&self) -> crate::Result { let events = reqwest::get(self.url.clone()) .await? .json::>() diff --git a/client/src/error.rs b/client/src/error.rs new file mode 100644 index 0000000..ba23c88 --- /dev/null +++ b/client/src/error.rs @@ -0,0 +1,7 @@ +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("HTTP error {0}")] + HttpError(#[from] reqwest::Error), +} + +pub type Result = std::result::Result; diff --git a/client/src/lib.rs b/client/src/lib.rs index a459a1c..6c32224 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -1,4 +1,12 @@ +pub mod announcer; mod client; +mod error; pub mod schedule; -pub use crate::client::Client; +pub use crate::{ + client::Client, + error::{Error, Result}, +}; + +#[cfg(test)] +mod testing; diff --git a/client/src/schedule/event/mod.rs b/client/src/schedule/event/mod.rs index 0f39e94..5f10ce8 100644 --- a/client/src/schedule/event/mod.rs +++ b/client/src/schedule/event/mod.rs @@ -52,13 +52,13 @@ pub struct Event { impl Event { #[cfg(test)] - pub fn dummy(start: DateTime) -> Self { + pub fn dummy(id: u32, start: DateTime) -> Self { use chrono::Duration; let duration = Duration::try_hours(1).unwrap(); Self { - id: 0, + id, slug: "".to_owned(), start, end: start + duration, @@ -72,7 +72,7 @@ impl Event { may_record: None, is_family_friendly: None, link: Url::parse("http://example.com").unwrap(), - extra: Default::default(), + extra: HashMap::default(), } } @@ -119,39 +119,49 @@ mod test { #[test] fn relative_time_past() { - let event = - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()); + let event = Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ); let t = event.end + Duration::try_seconds(1).unwrap(); assert_eq!(event.relative_to(t), RelativeTime::Past); } #[test] fn relative_time_future() { - let event = - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()); + let event = Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ); let t = event.start - Duration::try_seconds(1).unwrap(); assert_eq!(event.relative_to(t), RelativeTime::Future); } #[test] fn relative_time_now_1() { - let event = - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()); + let event = Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ); assert_eq!(event.relative_to(event.start), RelativeTime::Now); } #[test] fn relative_time_now_2() { - let event = - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()); + let event = Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ); assert_eq!(event.relative_to(event.end), RelativeTime::Now); } #[test] #[should_panic] fn relative_time_now_panic() { - let mut event = - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()); + let mut event = Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ); event.end = event.start - Duration::try_minutes(5).unwrap(); assert_eq!(event.relative_to(event.start), RelativeTime::Now); } diff --git a/client/src/schedule/mod.rs b/client/src/schedule/mod.rs index f8e35e3..8138321 100644 --- a/client/src/schedule/mod.rs +++ b/client/src/schedule/mod.rs @@ -6,7 +6,7 @@ use self::mutation::Mutators; use chrono::{DateTime, FixedOffset}; use std::collections::HashSet; -#[derive(Debug)] +#[derive(Debug, PartialEq, Eq)] pub struct Schedule { pub events: Vec, } diff --git a/client/src/schedule/mutation/at_venues.rs b/client/src/schedule/mutation/at_venues.rs index b72dc2a..f6ae780 100644 --- a/client/src/schedule/mutation/at_venues.rs +++ b/client/src/schedule/mutation/at_venues.rs @@ -26,6 +26,7 @@ mod test { let events = vec![ { let mut e = Event::dummy( + 0, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -33,6 +34,7 @@ mod test { }, { let mut e = Event::dummy( + 1, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 2".to_owned(); @@ -40,6 +42,7 @@ mod test { }, { let mut e = Event::dummy( + 2, DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); diff --git a/client/src/schedule/mutation/ends_after.rs b/client/src/schedule/mutation/ends_after.rs index 76e0046..b5e4a61 100644 --- a/client/src/schedule/mutation/ends_after.rs +++ b/client/src/schedule/mutation/ends_after.rs @@ -25,9 +25,18 @@ mod test { #[test] fn basic() { let events = vec![ - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T22:00:00+00:00").unwrap()), + Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ), + Event::dummy( + 1, + DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), + ), + Event::dummy( + 2, + DateTime::parse_from_rfc3339("2024-03-12T22:00:00+00:00").unwrap(), + ), ]; let mutator = diff --git a/client/src/schedule/mutation/fake_start_epoch.rs b/client/src/schedule/mutation/fake_start_epoch.rs index ef15176..278d160 100644 --- a/client/src/schedule/mutation/fake_start_epoch.rs +++ b/client/src/schedule/mutation/fake_start_epoch.rs @@ -39,9 +39,18 @@ mod test { #[test] fn basic() { let events = vec![ - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T22:00:00+00:00").unwrap()), + Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ), + Event::dummy( + 1, + DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), + ), + Event::dummy( + 2, + DateTime::parse_from_rfc3339("2024-03-12T22:00:00+00:00").unwrap(), + ), ]; let mutator = diff --git a/client/src/schedule/mutation/sorted_by_start_time.rs b/client/src/schedule/mutation/sorted_by_start_time.rs index f875623..befca4f 100644 --- a/client/src/schedule/mutation/sorted_by_start_time.rs +++ b/client/src/schedule/mutation/sorted_by_start_time.rs @@ -17,9 +17,18 @@ mod test { #[test] fn basic() { let events = vec![ - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:30:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()), + Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:30:00+00:00").unwrap(), + ), + Event::dummy( + 1, + DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), + ), + Event::dummy( + 2, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ), ]; let mutator = SortedByStartTime::default(); diff --git a/client/src/schedule/mutation/starts_after.rs b/client/src/schedule/mutation/starts_after.rs index 66f03e7..94efdf9 100644 --- a/client/src/schedule/mutation/starts_after.rs +++ b/client/src/schedule/mutation/starts_after.rs @@ -25,9 +25,18 @@ mod test { #[test] fn basic() { let events = vec![ - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap()), - Event::dummy(DateTime::parse_from_rfc3339("2024-03-12T22:00:00+00:00").unwrap()), + Event::dummy( + 0, + DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), + ), + Event::dummy( + 1, + DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), + ), + Event::dummy( + 2, + DateTime::parse_from_rfc3339("2024-03-12T22:00:00+00:00").unwrap(), + ), ]; let mutator = diff --git a/client/src/schedule/now_and_next.rs b/client/src/schedule/now_and_next.rs index b9c294c..d8016a0 100644 --- a/client/src/schedule/now_and_next.rs +++ b/client/src/schedule/now_and_next.rs @@ -82,6 +82,7 @@ mod test { let events = vec![ { let mut e = Event::dummy( + 0, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -89,6 +90,7 @@ mod test { }, { let mut e = Event::dummy( + 1, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 2".to_owned(); @@ -96,6 +98,7 @@ mod test { }, { let mut e = Event::dummy( + 2, DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -122,6 +125,7 @@ mod test { let events = vec![ { let mut e = Event::dummy( + 0, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -129,6 +133,7 @@ mod test { }, { let mut e = Event::dummy( + 1, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 2".to_owned(); @@ -136,6 +141,7 @@ mod test { }, { let mut e = Event::dummy( + 2, DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -162,6 +168,7 @@ mod test { let events = vec![ { let mut e = Event::dummy( + 0, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -169,6 +176,7 @@ mod test { }, { let mut e = Event::dummy( + 1, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 2".to_owned(); @@ -176,6 +184,7 @@ mod test { }, { let mut e = Event::dummy( + 2, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -183,6 +192,7 @@ mod test { }, { let mut e = Event::dummy( + 3, DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -209,6 +219,7 @@ mod test { let events = vec![ { let mut e = Event::dummy( + 0, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -216,6 +227,7 @@ mod test { }, { let mut e = Event::dummy( + 1, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 2".to_owned(); @@ -223,6 +235,7 @@ mod test { }, { let mut e = Event::dummy( + 2, DateTime::parse_from_rfc3339("2024-03-12T20:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); @@ -230,6 +243,7 @@ mod test { }, { let mut e = Event::dummy( + 3, DateTime::parse_from_rfc3339("2024-03-12T21:00:00+00:00").unwrap(), ); e.venue = "venue 1".to_owned(); diff --git a/client/src/testing.rs b/client/src/testing.rs new file mode 100644 index 0000000..fbfd7eb --- /dev/null +++ b/client/src/testing.rs @@ -0,0 +1,94 @@ +use crate::schedule::event::Event; +use axum::{ + extract::State, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use std::{ + net::SocketAddr, + sync::{Arc, Mutex}, +}; +use tokio::{net::TcpListener, task::JoinHandle}; +use url::Url; + +type Events = Arc>>; + +pub(crate) struct DummyScheduleServer { + events: Events, + + port: u16, + handle: Option>, +} + +impl DummyScheduleServer { + pub(crate) async fn new(port: u16) -> Self { + let events = Arc::new(Mutex::default()); + + let app = Router::new() + .route("/schedule", get(schedule)) + .with_state(events.clone()); + + let address = SocketAddr::new("127.0.0.1".parse().unwrap(), port); + + let listener = TcpListener::bind(address).await.unwrap(); + + let handle = Some(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap() + })); + + Self { + events, + port, + handle, + } + } + + pub(crate) fn url(&self) -> Url { + Url::parse(&format!("http://localhost:{}/schedule", self.port)).unwrap() + } + + pub(crate) fn set_events(&self, events: Vec) { + *self.events.lock().unwrap() = events; + } + + pub(crate) async fn stop(&mut self) { + if let Some(handle) = self.handle.take() { + handle.abort(); + let _ = handle.await; + } + } +} + +async fn schedule(State(state): State) -> Response { + let events = state.lock().unwrap().clone(); + Json(events).into_response() +} + +#[macro_export] +macro_rules! assert_future_in { + ($future:expr, $expected_at:expr, $expected_value:expr) => { + let result = tokio::time::timeout(Duration::from_secs(120), $future) + .await + .expect("future should not timeout"); + + let finish = tokio::time::Instant::now(); + + let tolerance = Duration::from_millis(500); + let late = finish.checked_duration_since($expected_at); + let early = $expected_at.checked_duration_since(finish); + + if let Some(late) = late { + if late > tolerance { + panic!("Future exited {:?} late with: {:?}", late, result); + } + } else if let Some(early) = early { + if early > tolerance { + panic!("Future exited {:?} early with: {:?}", early, result); + } + } + + let result = result.expect("a value from the future"); + assert_eq!(result, $expected_value); + }; +}