1use super::loop_helpers::{ContentSafety, ContentStorage, TopicScorer, TweetGenerator};
8use super::schedule::{apply_slot_jitter, schedule_gate, ActiveSchedule};
9use super::scheduler::LoopScheduler;
10use rand::seq::SliceRandom;
11use rand::SeedableRng;
12use std::sync::Arc;
13use tokio_util::sync::CancellationToken;
14
15const EXPLOIT_RATIO: f64 = 0.8;
17
18pub struct ContentLoop {
20 generator: Arc<dyn TweetGenerator>,
21 safety: Arc<dyn ContentSafety>,
22 storage: Arc<dyn ContentStorage>,
23 topic_scorer: Option<Arc<dyn TopicScorer>>,
24 topics: Vec<String>,
25 post_window_secs: u64,
26 dry_run: bool,
27}
28
29#[derive(Debug)]
31pub enum ContentResult {
32 Posted { topic: String, content: String },
34 TooSoon { elapsed_secs: u64, window_secs: u64 },
36 RateLimited,
38 NoTopics,
40 Failed { error: String },
42}
43
44impl ContentLoop {
45 pub fn new(
47 generator: Arc<dyn TweetGenerator>,
48 safety: Arc<dyn ContentSafety>,
49 storage: Arc<dyn ContentStorage>,
50 topics: Vec<String>,
51 post_window_secs: u64,
52 dry_run: bool,
53 ) -> Self {
54 Self {
55 generator,
56 safety,
57 storage,
58 topic_scorer: None,
59 topics,
60 post_window_secs,
61 dry_run,
62 }
63 }
64
65 pub fn with_topic_scorer(mut self, scorer: Arc<dyn TopicScorer>) -> Self {
70 self.topic_scorer = Some(scorer);
71 self
72 }
73
74 pub async fn run(
76 &self,
77 cancel: CancellationToken,
78 scheduler: LoopScheduler,
79 schedule: Option<Arc<ActiveSchedule>>,
80 ) {
81 let slot_mode = schedule.as_ref().is_some_and(|s| s.has_preferred_times());
82
83 tracing::info!(
84 dry_run = self.dry_run,
85 topics = self.topics.len(),
86 window_secs = self.post_window_secs,
87 slot_mode = slot_mode,
88 "Content loop started"
89 );
90
91 if self.topics.is_empty() {
92 tracing::warn!("No topics configured, content loop has nothing to post");
93 cancel.cancelled().await;
94 return;
95 }
96
97 let min_recent = 3usize;
98 let max_recent = (self.topics.len() / 2)
99 .max(min_recent)
100 .min(self.topics.len());
101 let mut recent_topics: Vec<String> = Vec::with_capacity(max_recent);
102 let mut rng = rand::rngs::StdRng::from_entropy();
103
104 loop {
105 if cancel.is_cancelled() {
106 break;
107 }
108
109 if !schedule_gate(&schedule, &cancel).await {
110 break;
111 }
112
113 if slot_mode {
114 let sched = schedule.as_ref().expect("slot_mode requires schedule");
116
117 let today_posts = match self.storage.todays_tweet_times().await {
119 Ok(times) => times,
120 Err(e) => {
121 tracing::warn!(error = %e, "Failed to query today's tweet times");
122 Vec::new()
123 }
124 };
125
126 match sched.next_unused_slot(&today_posts) {
127 Some((wait, slot)) => {
128 let jittered_wait = apply_slot_jitter(wait);
129 tracing::info!(
130 slot = %slot.format(),
131 wait_secs = jittered_wait.as_secs(),
132 "Slot mode: sleeping until next posting slot"
133 );
134
135 tokio::select! {
136 _ = cancel.cancelled() => break,
137 _ = tokio::time::sleep(jittered_wait) => {},
138 }
139
140 if cancel.is_cancelled() {
141 break;
142 }
143
144 let result = self
146 .run_slot_iteration(&mut recent_topics, max_recent, &mut rng)
147 .await;
148 self.log_content_result(&result);
149 }
150 None => {
151 tracing::info!(
153 "Slot mode: all slots used today, sleeping until next active period"
154 );
155 if let Some(sched) = &schedule {
156 let wait = sched.time_until_active();
157 if wait.is_zero() {
158 tokio::select! {
160 _ = cancel.cancelled() => break,
161 _ = tokio::time::sleep(std::time::Duration::from_secs(3600)) => {},
162 }
163 } else {
164 tokio::select! {
165 _ = cancel.cancelled() => break,
166 _ = tokio::time::sleep(wait) => {},
167 }
168 }
169 } else {
170 tokio::select! {
171 _ = cancel.cancelled() => break,
172 _ = tokio::time::sleep(std::time::Duration::from_secs(3600)) => {},
173 }
174 }
175 }
176 }
177 } else {
178 let result = self
180 .run_iteration(&mut recent_topics, max_recent, &mut rng)
181 .await;
182 self.log_content_result(&result);
183
184 tokio::select! {
185 _ = cancel.cancelled() => break,
186 _ = scheduler.tick() => {},
187 }
188 }
189 }
190
191 tracing::info!("Content loop stopped");
192 }
193
194 fn log_content_result(&self, result: &ContentResult) {
196 match result {
197 ContentResult::Posted { topic, content } => {
198 tracing::info!(
199 topic = %topic,
200 chars = content.len(),
201 dry_run = self.dry_run,
202 "Content iteration: tweet posted"
203 );
204 }
205 ContentResult::TooSoon {
206 elapsed_secs,
207 window_secs,
208 } => {
209 tracing::debug!(
210 elapsed = elapsed_secs,
211 window = window_secs,
212 "Content iteration: too soon since last tweet"
213 );
214 }
215 ContentResult::RateLimited => {
216 tracing::info!("Content iteration: daily tweet limit reached");
217 }
218 ContentResult::NoTopics => {
219 tracing::warn!("Content iteration: no topics available");
220 }
221 ContentResult::Failed { error } => {
222 tracing::warn!(error = %error, "Content iteration: failed");
223 }
224 }
225 }
226
227 async fn run_slot_iteration(
229 &self,
230 recent_topics: &mut Vec<String>,
231 max_recent: usize,
232 rng: &mut impl rand::Rng,
233 ) -> ContentResult {
234 if let Some(result) = self.try_post_scheduled().await {
236 return result;
237 }
238
239 if !self.safety.can_post_tweet().await {
241 return ContentResult::RateLimited;
242 }
243
244 let topic = self.pick_topic_epsilon_greedy(recent_topics, rng).await;
246
247 let result = self.generate_and_post(&topic).await;
248
249 if matches!(result, ContentResult::Posted { .. }) {
251 if recent_topics.len() >= max_recent {
252 recent_topics.remove(0);
253 }
254 recent_topics.push(topic);
255 }
256
257 result
258 }
259
260 pub async fn run_once(&self, topic: Option<&str>) -> ContentResult {
265 let chosen_topic = match topic {
266 Some(t) => t.to_string(),
267 None => {
268 if self.topics.is_empty() {
269 return ContentResult::NoTopics;
270 }
271 let mut rng = rand::thread_rng();
272 self.topics
273 .choose(&mut rng)
274 .expect("topics is non-empty")
275 .clone()
276 }
277 };
278
279 if !self.safety.can_post_tweet().await {
281 return ContentResult::RateLimited;
282 }
283
284 self.generate_and_post(&chosen_topic).await
285 }
286
287 async fn run_iteration(
289 &self,
290 recent_topics: &mut Vec<String>,
291 max_recent: usize,
292 rng: &mut impl rand::Rng,
293 ) -> ContentResult {
294 if let Some(result) = self.try_post_scheduled().await {
296 return result;
297 }
298
299 match self.storage.last_tweet_time().await {
301 Ok(Some(last_time)) => {
302 let elapsed = chrono::Utc::now()
303 .signed_duration_since(last_time)
304 .num_seconds()
305 .max(0) as u64;
306
307 if elapsed < self.post_window_secs {
308 return ContentResult::TooSoon {
309 elapsed_secs: elapsed,
310 window_secs: self.post_window_secs,
311 };
312 }
313 }
314 Ok(None) => {
315 }
317 Err(e) => {
318 tracing::warn!(error = %e, "Failed to query last tweet time, proceeding anyway");
319 }
320 }
321
322 if !self.safety.can_post_tweet().await {
324 return ContentResult::RateLimited;
325 }
326
327 let topic = self.pick_topic_epsilon_greedy(recent_topics, rng).await;
329
330 let result = self.generate_and_post(&topic).await;
331
332 if matches!(result, ContentResult::Posted { .. }) {
334 if recent_topics.len() >= max_recent {
335 recent_topics.remove(0);
336 }
337 recent_topics.push(topic);
338 }
339
340 result
341 }
342
343 async fn pick_topic_epsilon_greedy(
352 &self,
353 recent_topics: &mut Vec<String>,
354 rng: &mut impl rand::Rng,
355 ) -> String {
356 if let Some(scorer) = &self.topic_scorer {
357 let roll: f64 = rng.gen();
358 if roll < EXPLOIT_RATIO {
359 if let Ok(top_topics) = scorer.get_top_topics(10).await {
361 let candidates: Vec<&String> = top_topics
363 .iter()
364 .filter(|t| self.topics.contains(t) && !recent_topics.contains(t))
365 .collect();
366
367 if !candidates.is_empty() {
368 let topic = candidates[0].clone();
369 tracing::debug!(topic = %topic, "Epsilon-greedy: exploiting top topic");
370 return topic;
371 }
372 }
373 tracing::debug!("Epsilon-greedy: no top topics available, falling back to random");
375 } else {
376 tracing::debug!("Epsilon-greedy: exploring random topic");
377 }
378 }
379
380 pick_topic(&self.topics, recent_topics, rng)
381 }
382
383 async fn try_post_scheduled(&self) -> Option<ContentResult> {
388 match self.storage.next_scheduled_item().await {
389 Ok(Some((id, content_type, content))) => {
390 tracing::info!(
391 id = id,
392 content_type = %content_type,
393 "Posting scheduled content"
394 );
395
396 if self.dry_run {
397 tracing::info!(
398 "DRY RUN: Would post scheduled {} (id={}): \"{}\"",
399 content_type,
400 id,
401 &content[..content.len().min(80)]
402 );
403 let _ = self
404 .storage
405 .log_action(
406 &content_type,
407 "dry_run",
408 &format!("Scheduled id={id}: {}", &content[..content.len().min(80)]),
409 )
410 .await;
411 } else if let Err(e) = self.storage.post_tweet("scheduled", &content).await {
412 tracing::error!(error = %e, "Failed to post scheduled content");
413 return Some(ContentResult::Failed {
414 error: format!("Scheduled post failed: {e}"),
415 });
416 } else {
417 let _ = self.storage.mark_scheduled_posted(id, None).await;
418 let _ = self
419 .storage
420 .log_action(
421 &content_type,
422 "success",
423 &format!("Scheduled id={id}: {}", &content[..content.len().min(80)]),
424 )
425 .await;
426 }
427
428 Some(ContentResult::Posted {
429 topic: format!("scheduled:{id}"),
430 content,
431 })
432 }
433 Ok(None) => None,
434 Err(e) => {
435 tracing::warn!(error = %e, "Failed to check scheduled content");
436 None
437 }
438 }
439 }
440
441 async fn generate_and_post(&self, topic: &str) -> ContentResult {
443 tracing::info!(topic = %topic, "Generating tweet on topic");
444
445 let content = match self.generator.generate_tweet(topic).await {
447 Ok(text) => text,
448 Err(e) => {
449 return ContentResult::Failed {
450 error: format!("Generation failed: {e}"),
451 }
452 }
453 };
454
455 let content = if crate::content::length::tweet_weighted_len(&content)
457 > crate::content::length::MAX_TWEET_CHARS
458 {
459 tracing::debug!(
461 chars = content.len(),
462 "Generated tweet too long, retrying with shorter instruction"
463 );
464
465 let shorter_topic = format!("{topic} (IMPORTANT: keep under 280 characters)");
466 match self.generator.generate_tweet(&shorter_topic).await {
467 Ok(text)
468 if crate::content::length::tweet_weighted_len(&text)
469 <= crate::content::length::MAX_TWEET_CHARS =>
470 {
471 text
472 }
473 Ok(text) => {
474 tracing::warn!(
476 chars = text.len(),
477 "Retry still too long, truncating at word boundary"
478 );
479 truncate_at_word_boundary(&text, 280)
480 }
481 Err(e) => {
482 tracing::warn!(error = %e, "Retry generation failed, truncating original");
484 truncate_at_word_boundary(&content, 280)
485 }
486 }
487 } else {
488 content
489 };
490
491 if self.dry_run {
492 tracing::info!(
493 "DRY RUN: Would post tweet on topic '{}': \"{}\" ({} chars)",
494 topic,
495 content,
496 content.len()
497 );
498
499 let _ = self
500 .storage
501 .log_action(
502 "tweet",
503 "dry_run",
504 &format!("Topic '{}': {}", topic, truncate_display(&content, 80)),
505 )
506 .await;
507 } else {
508 if let Err(e) = self.storage.post_tweet(topic, &content).await {
509 tracing::error!(error = %e, "Failed to post tweet");
510 let _ = self
511 .storage
512 .log_action("tweet", "failure", &format!("Post failed: {e}"))
513 .await;
514 return ContentResult::Failed {
515 error: e.to_string(),
516 };
517 }
518
519 let _ = self
520 .storage
521 .log_action(
522 "tweet",
523 "success",
524 &format!("Topic '{}': {}", topic, truncate_display(&content, 80)),
525 )
526 .await;
527 }
528
529 ContentResult::Posted {
530 topic: topic.to_string(),
531 content,
532 }
533 }
534}
535
536fn pick_topic(topics: &[String], recent: &mut Vec<String>, rng: &mut impl rand::Rng) -> String {
539 let available: Vec<&String> = topics.iter().filter(|t| !recent.contains(t)).collect();
540
541 if available.is_empty() {
542 recent.clear();
544 topics.choose(rng).expect("topics is non-empty").clone()
545 } else {
546 available
547 .choose(rng)
548 .expect("available is non-empty")
549 .to_string()
550 }
551}
552
553fn truncate_at_word_boundary(s: &str, max_len: usize) -> String {
555 if s.len() <= max_len {
556 return s.to_string();
557 }
558
559 let cutoff = max_len.saturating_sub(3);
561 match s[..cutoff].rfind(' ') {
562 Some(pos) => format!("{}...", &s[..pos]),
563 None => format!("{}...", &s[..cutoff]),
564 }
565}
566
567fn truncate_display(s: &str, max_len: usize) -> String {
569 if s.len() <= max_len {
570 s.to_string()
571 } else {
572 format!("{}...", &s[..max_len])
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::automation::ContentLoopError;
580 use std::sync::Mutex;
581
582 struct MockGenerator {
585 response: String,
586 }
587
588 #[async_trait::async_trait]
589 impl TweetGenerator for MockGenerator {
590 async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
591 Ok(self.response.clone())
592 }
593 }
594
595 struct OverlongGenerator {
596 first_response: String,
597 retry_response: String,
598 call_count: Mutex<usize>,
599 }
600
601 #[async_trait::async_trait]
602 impl TweetGenerator for OverlongGenerator {
603 async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
604 let mut count = self.call_count.lock().expect("lock");
605 *count += 1;
606 if *count == 1 {
607 Ok(self.first_response.clone())
608 } else {
609 Ok(self.retry_response.clone())
610 }
611 }
612 }
613
614 struct FailingGenerator;
615
616 #[async_trait::async_trait]
617 impl TweetGenerator for FailingGenerator {
618 async fn generate_tweet(&self, _topic: &str) -> Result<String, ContentLoopError> {
619 Err(ContentLoopError::LlmFailure(
620 "model unavailable".to_string(),
621 ))
622 }
623 }
624
625 struct MockSafety {
626 can_tweet: bool,
627 can_thread: bool,
628 }
629
630 #[async_trait::async_trait]
631 impl ContentSafety for MockSafety {
632 async fn can_post_tweet(&self) -> bool {
633 self.can_tweet
634 }
635 async fn can_post_thread(&self) -> bool {
636 self.can_thread
637 }
638 }
639
640 struct MockStorage {
641 last_tweet: Mutex<Option<chrono::DateTime<chrono::Utc>>>,
642 posted_tweets: Mutex<Vec<(String, String)>>,
643 actions: Mutex<Vec<(String, String, String)>>,
644 }
645
646 impl MockStorage {
647 fn new(last_tweet: Option<chrono::DateTime<chrono::Utc>>) -> Self {
648 Self {
649 last_tweet: Mutex::new(last_tweet),
650 posted_tweets: Mutex::new(Vec::new()),
651 actions: Mutex::new(Vec::new()),
652 }
653 }
654
655 fn posted_count(&self) -> usize {
656 self.posted_tweets.lock().expect("lock").len()
657 }
658
659 fn action_count(&self) -> usize {
660 self.actions.lock().expect("lock").len()
661 }
662 }
663
664 #[async_trait::async_trait]
665 impl ContentStorage for MockStorage {
666 async fn last_tweet_time(
667 &self,
668 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
669 Ok(*self.last_tweet.lock().expect("lock"))
670 }
671
672 async fn last_thread_time(
673 &self,
674 ) -> Result<Option<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
675 Ok(None)
676 }
677
678 async fn todays_tweet_times(
679 &self,
680 ) -> Result<Vec<chrono::DateTime<chrono::Utc>>, ContentLoopError> {
681 Ok(Vec::new())
682 }
683
684 async fn post_tweet(&self, topic: &str, content: &str) -> Result<(), ContentLoopError> {
685 self.posted_tweets
686 .lock()
687 .expect("lock")
688 .push((topic.to_string(), content.to_string()));
689 Ok(())
690 }
691
692 async fn create_thread(
693 &self,
694 _topic: &str,
695 _tweet_count: usize,
696 ) -> Result<String, ContentLoopError> {
697 Ok("thread-1".to_string())
698 }
699
700 async fn update_thread_status(
701 &self,
702 _thread_id: &str,
703 _status: &str,
704 _tweet_count: usize,
705 _root_tweet_id: Option<&str>,
706 ) -> Result<(), ContentLoopError> {
707 Ok(())
708 }
709
710 async fn store_thread_tweet(
711 &self,
712 _thread_id: &str,
713 _position: usize,
714 _tweet_id: &str,
715 _content: &str,
716 ) -> Result<(), ContentLoopError> {
717 Ok(())
718 }
719
720 async fn log_action(
721 &self,
722 action_type: &str,
723 status: &str,
724 message: &str,
725 ) -> Result<(), ContentLoopError> {
726 self.actions.lock().expect("lock").push((
727 action_type.to_string(),
728 status.to_string(),
729 message.to_string(),
730 ));
731 Ok(())
732 }
733 }
734
735 fn make_topics() -> Vec<String> {
736 vec![
737 "Rust".to_string(),
738 "CLI tools".to_string(),
739 "Open source".to_string(),
740 "Developer productivity".to_string(),
741 ]
742 }
743
744 #[tokio::test]
747 async fn run_once_posts_tweet() {
748 let storage = Arc::new(MockStorage::new(None));
749 let content = ContentLoop::new(
750 Arc::new(MockGenerator {
751 response: "Great tweet about Rust!".to_string(),
752 }),
753 Arc::new(MockSafety {
754 can_tweet: true,
755 can_thread: true,
756 }),
757 storage.clone(),
758 make_topics(),
759 14400,
760 false,
761 );
762
763 let result = content.run_once(Some("Rust")).await;
764 assert!(matches!(result, ContentResult::Posted { .. }));
765 assert_eq!(storage.posted_count(), 1);
766 }
767
768 #[tokio::test]
769 async fn run_once_dry_run_does_not_post() {
770 let storage = Arc::new(MockStorage::new(None));
771 let content = ContentLoop::new(
772 Arc::new(MockGenerator {
773 response: "Great tweet about Rust!".to_string(),
774 }),
775 Arc::new(MockSafety {
776 can_tweet: true,
777 can_thread: true,
778 }),
779 storage.clone(),
780 make_topics(),
781 14400,
782 true,
783 );
784
785 let result = content.run_once(Some("Rust")).await;
786 assert!(matches!(result, ContentResult::Posted { .. }));
787 assert_eq!(storage.posted_count(), 0); assert_eq!(storage.action_count(), 1); }
790
791 #[tokio::test]
792 async fn run_once_rate_limited() {
793 let content = ContentLoop::new(
794 Arc::new(MockGenerator {
795 response: "tweet".to_string(),
796 }),
797 Arc::new(MockSafety {
798 can_tweet: false,
799 can_thread: true,
800 }),
801 Arc::new(MockStorage::new(None)),
802 make_topics(),
803 14400,
804 false,
805 );
806
807 let result = content.run_once(None).await;
808 assert!(matches!(result, ContentResult::RateLimited));
809 }
810
811 #[tokio::test]
812 async fn run_once_no_topics_returns_no_topics() {
813 let content = ContentLoop::new(
814 Arc::new(MockGenerator {
815 response: "tweet".to_string(),
816 }),
817 Arc::new(MockSafety {
818 can_tweet: true,
819 can_thread: true,
820 }),
821 Arc::new(MockStorage::new(None)),
822 Vec::new(),
823 14400,
824 false,
825 );
826
827 let result = content.run_once(None).await;
828 assert!(matches!(result, ContentResult::NoTopics));
829 }
830
831 #[tokio::test]
832 async fn run_once_generation_failure() {
833 let content = ContentLoop::new(
834 Arc::new(FailingGenerator),
835 Arc::new(MockSafety {
836 can_tweet: true,
837 can_thread: true,
838 }),
839 Arc::new(MockStorage::new(None)),
840 make_topics(),
841 14400,
842 false,
843 );
844
845 let result = content.run_once(Some("Rust")).await;
846 assert!(matches!(result, ContentResult::Failed { .. }));
847 }
848
849 #[tokio::test]
850 async fn run_iteration_skips_when_too_soon() {
851 let now = chrono::Utc::now();
852 let last_tweet = now - chrono::Duration::hours(1);
854 let storage = Arc::new(MockStorage::new(Some(last_tweet)));
855
856 let content = ContentLoop::new(
857 Arc::new(MockGenerator {
858 response: "tweet".to_string(),
859 }),
860 Arc::new(MockSafety {
861 can_tweet: true,
862 can_thread: true,
863 }),
864 storage,
865 make_topics(),
866 14400, false,
868 );
869
870 let mut recent = Vec::new();
871 let mut rng = rand::thread_rng();
872 let result = content.run_iteration(&mut recent, 3, &mut rng).await;
873 assert!(matches!(result, ContentResult::TooSoon { .. }));
874 }
875
876 #[tokio::test]
877 async fn run_iteration_posts_when_window_elapsed() {
878 let now = chrono::Utc::now();
879 let last_tweet = now - chrono::Duration::hours(5);
881 let storage = Arc::new(MockStorage::new(Some(last_tweet)));
882
883 let content = ContentLoop::new(
884 Arc::new(MockGenerator {
885 response: "Fresh tweet!".to_string(),
886 }),
887 Arc::new(MockSafety {
888 can_tweet: true,
889 can_thread: true,
890 }),
891 storage.clone(),
892 make_topics(),
893 14400,
894 false,
895 );
896
897 let mut recent = Vec::new();
898 let mut rng = rand::thread_rng();
899 let result = content.run_iteration(&mut recent, 3, &mut rng).await;
900 assert!(matches!(result, ContentResult::Posted { .. }));
901 assert_eq!(storage.posted_count(), 1);
902 assert_eq!(recent.len(), 1);
903 }
904
905 #[tokio::test]
906 async fn overlong_tweet_gets_truncated() {
907 let long_text = "a ".repeat(200); let content = ContentLoop::new(
909 Arc::new(OverlongGenerator {
910 first_response: long_text.clone(),
911 retry_response: long_text, call_count: Mutex::new(0),
913 }),
914 Arc::new(MockSafety {
915 can_tweet: true,
916 can_thread: true,
917 }),
918 Arc::new(MockStorage::new(None)),
919 make_topics(),
920 14400,
921 true,
922 );
923
924 let result = content.run_once(Some("Rust")).await;
925 if let ContentResult::Posted { content, .. } = result {
926 assert!(content.len() <= 280);
927 } else {
928 panic!("Expected Posted result");
929 }
930 }
931
932 #[test]
933 fn truncate_at_word_boundary_short() {
934 let result = truncate_at_word_boundary("Hello world", 280);
935 assert_eq!(result, "Hello world");
936 }
937
938 #[test]
939 fn truncate_at_word_boundary_long() {
940 let text = "The quick brown fox jumps over the lazy dog and more words here";
941 let result = truncate_at_word_boundary(text, 30);
942 assert!(result.len() <= 30);
943 assert!(result.ends_with("..."));
944 }
945
946 #[test]
947 fn pick_topic_avoids_recent() {
948 let topics = make_topics();
949 let mut recent = vec!["Rust".to_string(), "CLI tools".to_string()];
950 let mut rng = rand::thread_rng();
951
952 for _ in 0..20 {
953 let topic = pick_topic(&topics, &mut recent, &mut rng);
954 assert_ne!(topic, "Rust");
956 assert_ne!(topic, "CLI tools");
957 }
958 }
959
960 #[test]
961 fn pick_topic_clears_when_all_recent() {
962 let topics = make_topics();
963 let mut recent = topics.clone();
964 let mut rng = rand::thread_rng();
965
966 let topic = pick_topic(&topics, &mut recent, &mut rng);
968 assert!(topics.contains(&topic));
969 assert!(recent.is_empty()); }
971
972 #[test]
973 fn truncate_display_short() {
974 assert_eq!(truncate_display("hello", 10), "hello");
975 }
976
977 #[test]
978 fn truncate_display_long() {
979 let result = truncate_display("hello world this is long", 10);
980 assert_eq!(result, "hello worl...");
981 }
982
983 struct MockTopicScorer {
986 top_topics: Vec<String>,
987 }
988
989 #[async_trait::async_trait]
990 impl TopicScorer for MockTopicScorer {
991 async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
992 Ok(self.top_topics.clone())
993 }
994 }
995
996 struct FailingTopicScorer;
997
998 #[async_trait::async_trait]
999 impl TopicScorer for FailingTopicScorer {
1000 async fn get_top_topics(&self, _limit: u32) -> Result<Vec<String>, ContentLoopError> {
1001 Err(ContentLoopError::StorageError("db error".to_string()))
1002 }
1003 }
1004
1005 #[tokio::test]
1006 async fn epsilon_greedy_exploits_top_topic() {
1007 let storage = Arc::new(MockStorage::new(None));
1008 let scorer = Arc::new(MockTopicScorer {
1009 top_topics: vec!["Rust".to_string()],
1010 });
1011
1012 let content = ContentLoop::new(
1013 Arc::new(MockGenerator {
1014 response: "tweet".to_string(),
1015 }),
1016 Arc::new(MockSafety {
1017 can_tweet: true,
1018 can_thread: true,
1019 }),
1020 storage,
1021 make_topics(),
1022 14400,
1023 false,
1024 )
1025 .with_topic_scorer(scorer);
1026
1027 let mut recent = Vec::new();
1028 let mut rng = FirstCallRng::low_roll();
1030
1031 let topic = content
1032 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1033 .await;
1034 assert_eq!(topic, "Rust");
1035 }
1036
1037 #[tokio::test]
1038 async fn epsilon_greedy_explores_when_roll_high() {
1039 let storage = Arc::new(MockStorage::new(None));
1040 let scorer = Arc::new(MockTopicScorer {
1041 top_topics: vec!["Rust".to_string()],
1042 });
1043
1044 let content = ContentLoop::new(
1045 Arc::new(MockGenerator {
1046 response: "tweet".to_string(),
1047 }),
1048 Arc::new(MockSafety {
1049 can_tweet: true,
1050 can_thread: true,
1051 }),
1052 storage,
1053 make_topics(),
1054 14400,
1055 false,
1056 )
1057 .with_topic_scorer(scorer);
1058
1059 let mut recent = Vec::new();
1060 let mut rng = FirstCallRng::high_roll();
1062
1063 let topic = content
1064 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1065 .await;
1066 assert!(make_topics().contains(&topic));
1067 }
1068
1069 #[tokio::test]
1070 async fn epsilon_greedy_falls_back_on_scorer_error() {
1071 let storage = Arc::new(MockStorage::new(None));
1072 let scorer = Arc::new(FailingTopicScorer);
1073
1074 let content = ContentLoop::new(
1075 Arc::new(MockGenerator {
1076 response: "tweet".to_string(),
1077 }),
1078 Arc::new(MockSafety {
1079 can_tweet: true,
1080 can_thread: true,
1081 }),
1082 storage,
1083 make_topics(),
1084 14400,
1085 false,
1086 )
1087 .with_topic_scorer(scorer);
1088
1089 let mut recent = Vec::new();
1090 let mut rng = FirstCallRng::low_roll();
1092
1093 let topic = content
1094 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1095 .await;
1096 assert!(make_topics().contains(&topic));
1097 }
1098
1099 #[tokio::test]
1100 async fn epsilon_greedy_without_scorer_picks_random() {
1101 let storage = Arc::new(MockStorage::new(None));
1102
1103 let content = ContentLoop::new(
1104 Arc::new(MockGenerator {
1105 response: "tweet".to_string(),
1106 }),
1107 Arc::new(MockSafety {
1108 can_tweet: true,
1109 can_thread: true,
1110 }),
1111 storage,
1112 make_topics(),
1113 14400,
1114 false,
1115 );
1116
1117 let mut recent = Vec::new();
1118 let mut rng = rand::thread_rng();
1119
1120 let topic = content
1121 .pick_topic_epsilon_greedy(&mut recent, &mut rng)
1122 .await;
1123 assert!(make_topics().contains(&topic));
1124 }
1125
1126 struct FirstCallRng {
1131 first_u64: Option<u64>,
1132 inner: rand::rngs::ThreadRng,
1133 }
1134
1135 impl FirstCallRng {
1136 fn low_roll() -> Self {
1138 Self {
1139 first_u64: Some(0),
1140 inner: rand::thread_rng(),
1141 }
1142 }
1143
1144 fn high_roll() -> Self {
1146 Self {
1147 first_u64: Some(u64::MAX),
1148 inner: rand::thread_rng(),
1149 }
1150 }
1151 }
1152
1153 impl rand::RngCore for FirstCallRng {
1154 fn next_u32(&mut self) -> u32 {
1155 self.inner.next_u32()
1156 }
1157 fn next_u64(&mut self) -> u64 {
1158 if let Some(val) = self.first_u64.take() {
1159 val
1160 } else {
1161 self.inner.next_u64()
1162 }
1163 }
1164 fn fill_bytes(&mut self, dest: &mut [u8]) {
1165 self.inner.fill_bytes(dest);
1166 }
1167 fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), rand::Error> {
1168 self.inner.try_fill_bytes(dest)
1169 }
1170 }
1171}