iCalendar import: parse_ical + API + MCP tool (#16)

This commit is contained in:
2026-07-12 14:46:15 +02:00
parent 6b0d060945
commit a7fa2ec4aa
9 changed files with 565 additions and 7 deletions

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::models::{ProgrammingBlock, ScheduleConfig};
use crate::value_objects::{FillStrategy, MediaFilter, Weekday};
use crate::models::{BlockContent, ProgrammingBlock, ScheduleConfig};
use crate::value_objects::{FillStrategy, InterstitialRule, MediaFilter, MidRollRule, Weekday};
use chrono::NaiveTime;
use std::collections::HashMap;
@@ -143,3 +143,194 @@ fn all_weekdays_mapped() {
);
}
}
#[test]
fn roundtrip_algorithmic_block() {
let block = ProgrammingBlock::new_algorithmic(
"Morning Cartoons",
NaiveTime::from_hms_opt(8, 0, 0).unwrap(),
120,
MediaFilter::default(),
FillStrategy::Random,
);
let original = make_config_with_blocks(vec![(Weekday::Monday, vec![block.clone()])]);
let ical = generate_ical("Test", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Monday);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.name(), "Morning Cartoons");
assert_eq!(b.start_time(), NaiveTime::from_hms_opt(8, 0, 0).unwrap());
assert_eq!(b.duration_mins(), 120);
assert_eq!(b.id(), block.id());
assert!(matches!(b.content(), BlockContent::Algorithmic { strategy: FillStrategy::Random, .. }));
}
#[test]
fn roundtrip_multi_day() {
let mon = ProgrammingBlock::new_algorithmic(
"Mon Block",
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Sequential,
);
let fri = ProgrammingBlock::new_algorithmic(
"Fri Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
90,
MediaFilter::default(),
FillStrategy::BestFit,
);
let original = make_config_with_blocks(vec![
(Weekday::Monday, vec![mon]),
(Weekday::Friday, vec![fri]),
]);
let ical = generate_ical("Multi", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Monday)[0].name(), "Mon Block");
assert_eq!(parsed.blocks_for(Weekday::Friday)[0].name(), "Fri Block");
}
#[test]
fn roundtrip_manual_block() {
let block = ProgrammingBlock::new_manual(
"Manual Show",
NaiveTime::from_hms_opt(14, 30, 0).unwrap(),
45,
vec![],
);
let original = make_config_with_blocks(vec![(Weekday::Wednesday, vec![block])]);
let ical = generate_ical("Manual", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Wednesday);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].name(), "Manual Show");
assert_eq!(blocks[0].duration_mins(), 45);
assert!(matches!(blocks[0].content(), BlockContent::Manual { .. }));
}
#[test]
fn roundtrip_with_interstitial_and_midroll() {
let mut block = ProgrammingBlock::new_algorithmic(
"Full Block",
NaiveTime::from_hms_opt(20, 0, 0).unwrap(),
180,
MediaFilter::default(),
FillStrategy::Random,
);
let interstitial = InterstitialRule::new(MediaFilter::default(), FillStrategy::Random, 30);
let midroll = MidRollRule::new(true, 15, 60, MediaFilter::default());
block = ProgrammingBlock::from_parts(
block.id(),
block.name().to_string(),
block.start_time(),
block.duration_mins(),
block.content().clone(),
Some(interstitial),
Some(midroll),
);
let original = make_config_with_blocks(vec![(Weekday::Saturday, vec![block])]);
let ical = generate_ical("Full", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Saturday);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].interstitial_rule().is_some());
assert!(blocks[0].mid_roll_rule().is_some());
assert_eq!(blocks[0].interstitial_rule().unwrap().min_gap_secs(), 30);
assert!(blocks[0].mid_roll_rule().unwrap().prefer_chapters());
}
#[test]
fn import_without_ktv_properties_uses_defaults() {
let ical = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Generic Event\r\n\
DTSTART:090000\r\n\
DURATION:PT1H\r\n\
RRULE:FREQ=WEEKLY;BYDAY=TU\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let parsed = parse_ical(ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Tuesday);
assert_eq!(blocks.len(), 1);
let b = &blocks[0];
assert_eq!(b.name(), "Generic Event");
assert_eq!(b.start_time(), NaiveTime::from_hms_opt(9, 0, 0).unwrap());
assert_eq!(b.duration_mins(), 60);
assert!(matches!(
b.content(),
BlockContent::Algorithmic { strategy: FillStrategy::Random, .. }
));
assert!(b.interstitial_rule().is_none());
assert!(b.mid_roll_rule().is_none());
}
#[test]
fn import_invalid_ical_missing_vcalendar() {
let result = parse_ical("not an ical file");
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("BEGIN:VCALENDAR"), "error: {err}");
}
#[test]
fn import_invalid_ical_bad_duration() {
let ical = "BEGIN:VCALENDAR\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Bad\r\n\
DTSTART:090000\r\n\
DURATION:INVALID\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let result = parse_ical(ical);
assert!(result.is_err());
}
#[test]
fn import_handles_line_folding() {
let long_summary = "A".repeat(100);
let block = ProgrammingBlock::new_algorithmic(
&long_summary,
NaiveTime::from_hms_opt(10, 0, 0).unwrap(),
60,
MediaFilter::default(),
FillStrategy::Random,
);
let original = make_config_with_blocks(vec![(Weekday::Thursday, vec![block])]);
let ical = generate_ical("Fold Test", "UTC", &original);
let parsed = parse_ical(&ical).unwrap();
let blocks = parsed.blocks_for(Weekday::Thursday);
assert_eq!(blocks.len(), 1);
}
#[test]
fn import_multiple_byday() {
let ical = "BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
SUMMARY:Weekday Show\r\n\
DTSTART:180000\r\n\
DURATION:PT2H\r\n\
RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let parsed = parse_ical(ical).unwrap();
assert_eq!(parsed.blocks_for(Weekday::Monday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Wednesday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Friday).len(), 1);
assert_eq!(parsed.blocks_for(Weekday::Tuesday).len(), 0);
}