use super::*;
use crate::automation::watchtower::loopback::{LoopBackEntry, TuitbotFrontMatter};
fn sample_percentiles(sufficient: bool) -> PerformancePercentiles {
PerformancePercentiles {
p50_impressions: 500,
p90_impressions: 2000,
has_sufficient_data: sufficient,
}
}
fn entry(tweet_id: &str, impressions: Option<i64>, synced: Option<&str>) -> LoopBackEntry {
LoopBackEntry {
tweet_id: tweet_id.to_string(),
url: format!("https://x.com/u/status/{tweet_id}"),
published_at: "2026-03-01T10:00:00Z".to_string(),
content_type: "tweet".to_string(),
status: None,
thread_url: None,
child_tweet_ids: None,
impressions,
likes: impressions.map(|i| i / 10),
retweets: None,
replies: None,
engagement_rate: None,
performance_score: None,
synced_at: synced.map(String::from),
}
}
fn sample_analytics(impressions: i64) -> EntryAnalytics {
EntryAnalytics {
impressions,
likes: impressions / 10,
retweets: impressions / 20,
replies: impressions / 50,
engagement_rate: Some(1.8),
performance_score: Some(80.0),
synced_at: "2026-03-01T12:00:00Z".into(),
}
}
#[test]
fn aggregate_empty_returns_none() {
assert!(aggregate_thread_metrics(&[]).is_none());
}
#[test]
fn aggregate_single_tweet() {
let rows = vec![TweetPerformanceRow {
tweet_id: "t1".into(),
likes_received: 10,
retweets_received: 5,
replies_received: 3,
impressions: 1000,
performance_score: 80.0,
}];
let a = aggregate_thread_metrics(&rows).unwrap();
assert_eq!(a.impressions, 1000);
assert_eq!(a.likes, 10);
assert_eq!(a.retweets, 5);
assert_eq!(a.replies, 3);
let rate = a.engagement_rate.unwrap();
assert!((rate - 1.8).abs() < 0.01); assert!((a.performance_score.unwrap() - 80.0).abs() < 0.01);
}
#[test]
fn aggregate_multiple_tweets_weighted_score() {
let rows = vec![
TweetPerformanceRow {
tweet_id: "t1".into(),
likes_received: 10,
retweets_received: 2,
replies_received: 1,
impressions: 500,
performance_score: 60.0,
},
TweetPerformanceRow {
tweet_id: "t2".into(),
likes_received: 20,
retweets_received: 8,
replies_received: 4,
impressions: 1500,
performance_score: 90.0,
},
];
let a = aggregate_thread_metrics(&rows).unwrap();
assert_eq!(a.impressions, 2000);
assert_eq!(a.likes, 30);
assert_eq!(a.retweets, 10);
assert_eq!(a.replies, 5);
assert!((a.engagement_rate.unwrap() - 2.25).abs() < 0.01);
assert!((a.performance_score.unwrap() - 82.5).abs() < 0.01);
}
#[test]
fn aggregate_zero_impressions_yields_none_rates() {
let rows = vec![TweetPerformanceRow {
tweet_id: "t1".into(),
likes_received: 0,
retweets_received: 0,
replies_received: 0,
impressions: 0,
performance_score: 0.0,
}];
let a = aggregate_thread_metrics(&rows).unwrap();
assert_eq!(a.impressions, 0);
assert!(a.engagement_rate.is_none());
assert!(a.performance_score.is_none());
}
#[test]
fn aggregate_mixed_zero_and_nonzero_impressions() {
let rows = vec![
TweetPerformanceRow {
tweet_id: "t1".into(),
likes_received: 5,
retweets_received: 1,
replies_received: 0,
impressions: 0,
performance_score: 10.0,
},
TweetPerformanceRow {
tweet_id: "t2".into(),
likes_received: 20,
retweets_received: 5,
replies_received: 2,
impressions: 1000,
performance_score: 70.0,
},
];
let a = aggregate_thread_metrics(&rows).unwrap();
assert_eq!(a.impressions, 1000);
assert_eq!(a.likes, 25);
assert!((a.performance_score.unwrap() - 70.0).abs() < 0.01);
}
#[test]
fn aggregate_synced_at_is_populated() {
let rows = vec![TweetPerformanceRow {
tweet_id: "t1".into(),
likes_received: 1,
retweets_received: 0,
replies_received: 0,
impressions: 100,
performance_score: 50.0,
}];
let a = aggregate_thread_metrics(&rows).unwrap();
assert!(a.synced_at.contains('T'));
assert!(a.synced_at.ends_with('Z'));
}
#[test]
fn recompute_no_entries_sets_none() {
let mut fm = TuitbotFrontMatter::default();
recompute_summaries(&mut fm, &sample_percentiles(true));
let key = serde_yaml::Value::String("tuitbot_social_performance".into());
assert_eq!(fm.other.get(&key).unwrap().as_str().unwrap(), "none");
}
#[test]
fn recompute_high_tier() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![entry("t1", Some(3000), Some("2026-03-01T12:00:00Z"))],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(true));
let key = serde_yaml::Value::String("tuitbot_social_performance".into());
assert_eq!(fm.other.get(&key).unwrap().as_str().unwrap(), "high");
let imp_key = serde_yaml::Value::String("tuitbot_best_post_impressions".into());
assert_eq!(fm.other.get(&imp_key).unwrap().as_i64().unwrap(), 3000);
}
#[test]
fn recompute_medium_tier() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![entry("t1", Some(800), None)],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(true));
let key = serde_yaml::Value::String("tuitbot_social_performance".into());
assert_eq!(fm.other.get(&key).unwrap().as_str().unwrap(), "medium");
}
#[test]
fn recompute_low_tier() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![entry("t1", Some(100), None)],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(true));
let key = serde_yaml::Value::String("tuitbot_social_performance".into());
assert_eq!(fm.other.get(&key).unwrap().as_str().unwrap(), "low");
}
#[test]
fn recompute_insufficient_data_forces_none_tier() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![entry("t1", Some(5000), None)],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(false));
let key = serde_yaml::Value::String("tuitbot_social_performance".into());
assert_eq!(fm.other.get(&key).unwrap().as_str().unwrap(), "none");
}
#[test]
fn recompute_picks_best_by_impressions() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![
entry("t1", Some(100), None),
entry("t2", Some(5000), None),
entry("t3", Some(200), None),
],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(true));
let url_key = serde_yaml::Value::String("tuitbot_best_post_url".into());
let url = fm.other.get(&url_key).unwrap().as_str().unwrap();
assert!(url.contains("t2"));
}
#[test]
fn recompute_last_synced_at_picks_latest() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![
entry("t1", Some(100), Some("2026-03-01T10:00:00Z")),
entry("t2", Some(200), Some("2026-03-02T10:00:00Z")),
],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(true));
let key = serde_yaml::Value::String("tuitbot_last_synced_at".into());
assert_eq!(
fm.other.get(&key).unwrap().as_str().unwrap(),
"2026-03-02T10:00:00Z"
);
}
#[test]
fn recompute_clears_stale_summary_keys() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![],
other: serde_yaml::Mapping::new(),
};
let key = |s: &str| serde_yaml::Value::String(s.into());
let val = |s: &str| serde_yaml::Value::String(s.into());
fm.other
.insert(key("tuitbot_social_performance"), val("high"));
fm.other
.insert(key("tuitbot_best_post_impressions"), val("9999"));
recompute_summaries(&mut fm, &sample_percentiles(true));
assert_eq!(
fm.other
.get(&key("tuitbot_social_performance"))
.unwrap()
.as_str()
.unwrap(),
"none"
);
assert!(fm
.other
.get(&key("tuitbot_best_post_impressions"))
.is_none());
}
#[test]
fn recompute_entries_without_impressions_ignored() {
let mut fm = TuitbotFrontMatter {
tuitbot: vec![
entry("t1", None, None), entry("t2", Some(800), None), ],
other: serde_yaml::Mapping::new(),
};
recompute_summaries(&mut fm, &sample_percentiles(true));
let url_key = serde_yaml::Value::String("tuitbot_best_post_url".into());
let url = fm.other.get(&url_key).unwrap().as_str().unwrap();
assert!(url.contains("t2"));
}
#[test]
fn update_entry_file_not_found() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("missing.md");
let result = update_entry_analytics(
&path,
"t1",
&sample_analytics(100),
&sample_percentiles(true),
)
.unwrap();
assert_eq!(result, UpdateResult::FileNotFound);
}
#[test]
fn update_entry_not_found_in_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("note.md");
let content = "---\ntuitbot:\n - tweet_id: \"t1\"\n url: \"https://x.com/u/status/t1\"\n published_at: \"2026-03-01T10:00:00Z\"\n type: tweet\n---\nBody.\n";
std::fs::write(&path, content).unwrap();
let result = update_entry_analytics(
&path,
"t999",
&sample_analytics(100),
&sample_percentiles(true),
)
.unwrap();
assert_eq!(result, UpdateResult::EntryNotFound);
}
#[test]
fn update_entry_no_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plain.md");
std::fs::write(&path, "Just a plain note.\n").unwrap();
let result = update_entry_analytics(
&path,
"t1",
&sample_analytics(100),
&sample_percentiles(true),
)
.unwrap();
assert_eq!(result, UpdateResult::EntryNotFound);
}
#[test]
fn update_entry_writes_analytics_and_summaries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("note.md");
let content = "---\ntuitbot:\n - tweet_id: \"t1\"\n url: \"https://x.com/u/status/t1\"\n published_at: \"2026-03-01T10:00:00Z\"\n type: tweet\n---\nBody text here.\n";
std::fs::write(&path, content).unwrap();
let result = update_entry_analytics(
&path,
"t1",
&sample_analytics(3000),
&sample_percentiles(true),
)
.unwrap();
assert_eq!(result, UpdateResult::Updated);
let updated = std::fs::read_to_string(&path).unwrap();
assert!(updated.contains("impressions"));
assert!(updated.contains("3000"));
assert!(updated.contains("tuitbot_social_performance"));
assert!(updated.contains("Body text here."));
}
#[test]
fn update_entry_preserves_other_entries() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("multi.md");
let content = "---\ntuitbot:\n - tweet_id: \"t1\"\n url: \"https://x.com/u/status/t1\"\n published_at: \"2026-03-01T10:00:00Z\"\n type: tweet\n - tweet_id: \"t2\"\n url: \"https://x.com/u/status/t2\"\n published_at: \"2026-03-02T10:00:00Z\"\n type: tweet\n---\nBody.\n";
std::fs::write(&path, content).unwrap();
let result = update_entry_analytics(
&path,
"t1",
&sample_analytics(500),
&sample_percentiles(true),
)
.unwrap();
assert_eq!(result, UpdateResult::Updated);
let updated = std::fs::read_to_string(&path).unwrap();
assert!(updated.contains("t1"));
assert!(updated.contains("t2"));
}