use chrono::{TimeZone, Utc};
use truth_engine::availability::{
find_first_free_across, merge_availability, EventStream, PrivacyLevel,
};
use truth_engine::expander::ExpandedEvent;
fn event(start: &str, end: &str) -> ExpandedEvent {
ExpandedEvent {
start: start.parse().unwrap(),
end: end.parse().unwrap(),
}
}
fn stream(id: &str, events: Vec<ExpandedEvent>) -> EventStream {
EventStream {
stream_id: id.to_string(),
events,
}
}
#[test]
fn single_stream_matches_find_free_slots() {
let events = vec![
event("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"),
event("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z"),
];
let streams = vec![stream("work", events.clone())];
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(&streams, window_start, window_end, PrivacyLevel::Full);
assert_eq!(result.busy.len(), 2);
assert_eq!(result.busy[0].start, events[0].start);
assert_eq!(result.busy[0].end, events[0].end);
assert_eq!(result.busy[1].start, events[1].start);
assert_eq!(result.busy[1].end, events[1].end);
assert_eq!(result.free.len(), 3);
assert_eq!(result.free[0].duration_minutes, 60); assert_eq!(result.free[1].duration_minutes, 240); assert_eq!(result.free[2].duration_minutes, 120);
let direct_free = truth_engine::find_free_slots(&events, window_start, window_end);
assert_eq!(result.free, direct_free);
}
#[test]
fn two_non_overlapping_streams_merge_all_busy_blocks() {
let stream_a = stream(
"work",
vec![event("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z")],
);
let stream_b = stream(
"personal",
vec![event("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(
&[stream_a, stream_b],
window_start,
window_end,
PrivacyLevel::Full,
);
assert_eq!(result.busy.len(), 2);
assert_eq!(result.busy[0].source_count, 1); assert_eq!(result.busy[1].source_count, 1);
assert_eq!(result.free.len(), 3);
}
#[test]
fn two_overlapping_streams_merge_with_source_count_2() {
let stream_a = stream(
"work",
vec![event("2026-03-16T09:00:00Z", "2026-03-16T11:00:00Z")],
);
let stream_b = stream(
"personal",
vec![event("2026-03-16T10:00:00Z", "2026-03-16T12:00:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(
&[stream_a, stream_b],
window_start,
window_end,
PrivacyLevel::Full,
);
assert_eq!(result.busy.len(), 1);
assert_eq!(
result.busy[0].start,
Utc.with_ymd_and_hms(2026, 3, 16, 9, 0, 0).unwrap()
);
assert_eq!(
result.busy[0].end,
Utc.with_ymd_and_hms(2026, 3, 16, 12, 0, 0).unwrap()
);
assert_eq!(result.busy[0].source_count, 2);
assert_eq!(result.free.len(), 2);
assert_eq!(result.free[0].duration_minutes, 60);
assert_eq!(result.free[1].duration_minutes, 300);
}
#[test]
fn three_streams_cascading_overlaps() {
let stream_a = stream(
"work",
vec![event("2026-03-16T09:00:00Z", "2026-03-16T10:30:00Z")],
);
let stream_b = stream(
"personal",
vec![event("2026-03-16T10:00:00Z", "2026-03-16T11:30:00Z")],
);
let stream_c = stream(
"sideproject",
vec![event("2026-03-16T11:00:00Z", "2026-03-16T12:00:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(
&[stream_a, stream_b, stream_c],
window_start,
window_end,
PrivacyLevel::Full,
);
assert_eq!(result.busy.len(), 1);
assert_eq!(
result.busy[0].start,
Utc.with_ymd_and_hms(2026, 3, 16, 9, 0, 0).unwrap()
);
assert_eq!(
result.busy[0].end,
Utc.with_ymd_and_hms(2026, 3, 16, 12, 0, 0).unwrap()
);
assert_eq!(result.busy[0].source_count, 3);
}
#[test]
fn empty_streams_produce_full_window_free_slot() {
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(&[], window_start, window_end, PrivacyLevel::Full);
assert_eq!(result.busy.len(), 0);
assert_eq!(result.free.len(), 1);
assert_eq!(result.free[0].duration_minutes, 540);
let empty_a = stream("work", vec![]);
let empty_b = stream("personal", vec![]);
let result = merge_availability(
&[empty_a, empty_b],
window_start,
window_end,
PrivacyLevel::Full,
);
assert_eq!(result.busy.len(), 0);
assert_eq!(result.free.len(), 1);
assert_eq!(result.free[0].duration_minutes, 540);
}
#[test]
fn opaque_privacy_hides_source_count() {
let stream_a = stream(
"work",
vec![event("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z")],
);
let stream_b = stream(
"personal",
vec![event("2026-03-16T09:30:00Z", "2026-03-16T10:30:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(
&[stream_a, stream_b],
window_start,
window_end,
PrivacyLevel::Opaque,
);
assert_eq!(result.privacy, PrivacyLevel::Opaque);
for block in &result.busy {
assert_eq!(block.source_count, 0, "Opaque mode must hide source count");
}
}
#[test]
fn find_first_free_across_respects_min_duration() {
let stream_a = stream(
"work",
vec![event("2026-03-16T09:00:00Z", "2026-03-16T09:45:00Z")],
);
let stream_b = stream(
"personal",
vec![event("2026-03-16T10:00:00Z", "2026-03-16T12:00:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 9, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let slot = find_first_free_across(&[stream_a, stream_b], window_start, window_end, 60);
assert!(slot.is_some());
let slot = slot.unwrap();
assert_eq!(
slot.start,
Utc.with_ymd_and_hms(2026, 3, 16, 12, 0, 0).unwrap()
);
assert_eq!(slot.duration_minutes, 300); }
#[test]
fn events_outside_window_are_clipped() {
let stream_a = stream(
"work",
vec![
event("2026-03-16T07:00:00Z", "2026-03-16T09:30:00Z"),
event("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z"),
event("2026-03-16T16:30:00Z", "2026-03-16T18:00:00Z"),
event("2026-03-16T20:00:00Z", "2026-03-16T21:00:00Z"),
],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(&[stream_a], window_start, window_end, PrivacyLevel::Full);
assert_eq!(result.busy.len(), 3);
assert_eq!(result.busy[0].start, window_start);
assert_eq!(
result.busy[0].end,
Utc.with_ymd_and_hms(2026, 3, 16, 9, 30, 0).unwrap()
);
assert_eq!(
result.busy[2].start,
Utc.with_ymd_and_hms(2026, 3, 16, 16, 30, 0).unwrap()
);
assert_eq!(result.busy[2].end, window_end);
}
#[test]
fn all_day_event_across_streams() {
let stream_a = stream(
"work",
vec![event("2026-03-16T08:00:00Z", "2026-03-16T17:00:00Z")],
);
let stream_b = stream(
"personal",
vec![event("2026-03-16T12:00:00Z", "2026-03-16T13:00:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(
&[stream_a, stream_b],
window_start,
window_end,
PrivacyLevel::Full,
);
assert_eq!(result.busy.len(), 1);
assert_eq!(result.busy[0].start, window_start);
assert_eq!(result.busy[0].end, window_end);
assert_eq!(result.busy[0].source_count, 2);
assert_eq!(result.free.len(), 0);
}
#[test]
fn window_metadata_preserved() {
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(&[], window_start, window_end, PrivacyLevel::Opaque);
assert_eq!(result.window_start, window_start);
assert_eq!(result.window_end, window_end);
assert_eq!(result.privacy, PrivacyLevel::Opaque);
}
#[test]
fn multiple_events_in_single_stream_merge() {
let stream_a = stream(
"work",
vec![
event("2026-03-16T09:00:00Z", "2026-03-16T10:00:00Z"),
event("2026-03-16T09:30:00Z", "2026-03-16T10:30:00Z"), event("2026-03-16T14:00:00Z", "2026-03-16T15:00:00Z"),
],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let result = merge_availability(&[stream_a], window_start, window_end, PrivacyLevel::Full);
assert_eq!(result.busy.len(), 2);
assert_eq!(
result.busy[0].end,
Utc.with_ymd_and_hms(2026, 3, 16, 10, 30, 0).unwrap()
);
}
#[test]
fn find_first_free_across_no_qualifying_slot() {
let stream_a = stream(
"work",
vec![event("2026-03-16T08:00:00Z", "2026-03-16T17:00:00Z")],
);
let window_start = Utc.with_ymd_and_hms(2026, 3, 16, 8, 0, 0).unwrap();
let window_end = Utc.with_ymd_and_hms(2026, 3, 16, 17, 0, 0).unwrap();
let slot = find_first_free_across(&[stream_a], window_start, window_end, 30);
assert!(slot.is_none());
}