1use std::collections::HashSet;
2
3use crate::{
4 error::VecslideError,
5 manifest::{Presentation, TranscriptMode},
6};
7
8pub fn validate(presentation: &Presentation) -> Vec<VecslideError> {
11 let mut errors = Vec::new();
12
13 if matches!(&presentation.audio_track, Some(s) if s.is_empty()) {
15 errors.push(VecslideError::EmptyAudioTrack);
16 }
17
18 if presentation.slides.is_empty() {
20 errors.push(VecslideError::EmptySlides);
21 return errors; }
23
24 let mut seen_ids: HashSet<&str> = HashSet::new();
26 for slide in &presentation.slides {
27 if !seen_ids.insert(slide.id.as_str()) {
28 errors.push(VecslideError::DuplicateSlideId { id: slide.id.clone() });
29 }
30 }
31
32 let n = presentation.slides.len();
34 let mut prev_time = 0u64;
35 for (i, slide) in presentation.slides.iter().enumerate() {
36 if i > 0 && slide.time_start <= prev_time {
37 errors.push(VecslideError::TimestampOrder {
38 index: i,
39 detail: format!(
40 "slide '{}' time_start {}ms <= previous {}ms",
41 slide.id, slide.time_start, prev_time
42 ),
43 });
44 }
45
46 let slide_end = if i + 1 < n {
48 presentation.slides[i + 1].time_start
49 } else {
50 u64::MAX
51 };
52
53 for (j, anim) in slide.animations.iter().enumerate() {
54 if anim.element_id.is_empty() {
55 errors.push(VecslideError::EmptyElementId {
56 slide_id: slide.id.clone(),
57 anim_index: j,
58 });
59 }
60 if anim.time_start < slide.time_start || anim.time_start >= slide_end {
61 errors.push(VecslideError::AnimationOutOfRange { slide_id: slide.id.clone() });
62 }
63 }
64
65 prev_time = slide.time_start;
66 }
67
68 if let Some(ref transcript) = presentation.transcript {
70 if matches!(transcript.mode, TranscriptMode::Synchronized)
72 && presentation.audio_track.is_none()
73 {
74 errors.push(VecslideError::EmptyAudioTrack);
75 }
76
77 let mut prev_end = 0u64;
78 for (i, seg) in transcript.segments.iter().enumerate() {
79 if seg.start_ms >= seg.end_ms {
80 errors.push(VecslideError::InvalidSegment { index: i });
81 }
82 if i > 0 && seg.start_ms < prev_end {
83 errors.push(VecslideError::OverlappingSegments { index: i });
84 }
85 prev_end = seg.end_ms;
86 }
87 }
88
89 errors
90}
91
92#[cfg(feature = "native")]
95pub fn validate_with_dir(
96 presentation: &Presentation,
97 base_dir: &std::path::Path,
98) -> Vec<VecslideError> {
99 let mut errors = validate(presentation);
100
101 for slide in &presentation.slides {
102 if let Some(ref svg_file) = slide.svg_file {
103 let svg_path = base_dir.join(svg_file);
104 if !svg_path.exists() {
105 errors.push(VecslideError::MissingSvgFile {
106 path: svg_file.clone(),
107 });
108 }
109 }
110 }
111
112 errors
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::manifest::{Animation, Slide, Transcript, TranscriptMode, TranscriptSegment};
119
120 fn make_slide(id: &str, time_start: u64) -> Slide {
121 Slide {
122 id: id.to_string(),
123 svg_file: Some(format!("{id}.svg")),
124 typst_file: None,
125 typst_inline: None,
126 time_start,
127 animations: vec![],
128 pointer_trail: None,
129 transition: None,
130 }
131 }
132
133 fn make_presentation(slides: Vec<Slide>) -> Presentation {
134 Presentation {
135 format_version: "1.0".to_string(),
136 title: "Test".to_string(),
137 author: None,
138 description: None,
139 date: None,
140 language: None,
141 audio_track: Some("audio/test.opus".to_string()),
142 typst_source: None,
143 slides,
144 annotations: vec![],
145 transcript: None,
146 }
147 }
148
149 #[test]
150 fn valid_presentation_has_no_errors() {
151 let pres = make_presentation(vec![
152 make_slide("s1", 0),
153 make_slide("s2", 5000),
154 make_slide("s3", 10000),
155 ]);
156 assert!(validate(&pres).is_empty());
157 }
158
159 #[test]
160 fn empty_audio_track_is_an_error() {
161 let mut pres = make_presentation(vec![make_slide("s1", 0)]);
162 pres.audio_track = Some(String::new());
163 let errors = validate(&pres);
164 assert!(errors
165 .iter()
166 .any(|e| matches!(e, VecslideError::EmptyAudioTrack)));
167 }
168
169 #[test]
170 fn empty_slides_is_an_error() {
171 let pres = make_presentation(vec![]);
172 let errors = validate(&pres);
173 assert!(errors
174 .iter()
175 .any(|e| matches!(e, VecslideError::EmptySlides)));
176 }
177
178 #[test]
179 fn non_ascending_timestamps_are_detected() {
180 let pres = make_presentation(vec![
181 make_slide("s1", 0),
182 make_slide("s2", 10000),
183 make_slide("s3", 5000), ]);
185 let errors = validate(&pres);
186 assert!(errors
187 .iter()
188 .any(|e| matches!(e, VecslideError::TimestampOrder { index: 2, .. })));
189 }
190
191 #[test]
192 fn duplicate_slide_ids_are_detected() {
193 let pres = make_presentation(vec![
194 make_slide("s1", 0),
195 make_slide("s1", 5000), ]);
197 let errors = validate(&pres);
198 assert!(errors
199 .iter()
200 .any(|e| matches!(e, VecslideError::DuplicateSlideId { .. })));
201 }
202
203 #[test]
204 fn empty_element_id_in_animation_is_detected() {
205 let mut slide = make_slide("s1", 0);
206 slide.animations.push(Animation {
207 element_id: String::new(),
208 time_start: 0,
209 duration: 500,
210 kind: "fade-in".to_string(),
211 });
212 let pres = make_presentation(vec![slide]);
213 let errors = validate(&pres);
214 assert!(errors
215 .iter()
216 .any(|e| matches!(e, VecslideError::EmptyElementId { .. })));
217 }
218
219 #[test]
220 fn animation_out_of_range_is_detected() {
221 let mut slide = make_slide("s1", 1000);
222 slide.animations.push(Animation {
223 element_id: "el".to_string(),
224 time_start: 500, duration: 100,
226 kind: "fade-in".to_string(),
227 });
228 let pres = make_presentation(vec![slide]);
229 let errors = validate(&pres);
230 assert!(errors
231 .iter()
232 .any(|e| matches!(e, VecslideError::AnimationOutOfRange { .. })));
233 }
234
235 #[test]
236 fn invalid_transcript_segment_detected() {
237 let mut pres = make_presentation(vec![make_slide("s1", 0)]);
238 pres.transcript = Some(Transcript {
239 mode: TranscriptMode::Synchronized,
240 language: "it-IT".to_string(),
241 segments: vec![TranscriptSegment {
242 start_ms: 1000,
243 end_ms: 500, text: "hello".to_string(),
245 slide_ref: None,
246 words: vec![],
247 }],
248 });
249 let errors = validate(&pres);
250 assert!(errors
251 .iter()
252 .any(|e| matches!(e, VecslideError::InvalidSegment { .. })));
253 }
254
255 #[test]
256 fn overlapping_transcript_segments_detected() {
257 let mut pres = make_presentation(vec![make_slide("s1", 0)]);
258 pres.transcript = Some(Transcript {
259 mode: TranscriptMode::Synchronized,
260 language: "it-IT".to_string(),
261 segments: vec![
262 TranscriptSegment {
263 start_ms: 0,
264 end_ms: 2000,
265 text: "first".to_string(),
266 slide_ref: None,
267 words: vec![],
268 },
269 TranscriptSegment {
270 start_ms: 1500, end_ms: 3000,
272 text: "second".to_string(),
273 slide_ref: None,
274 words: vec![],
275 },
276 ],
277 });
278 let errors = validate(&pres);
279 assert!(errors
280 .iter()
281 .any(|e| matches!(e, VecslideError::OverlappingSegments { .. })));
282 }
283
284 #[test]
285 fn synchronized_transcript_requires_audio_track() {
286 let mut pres = make_presentation(vec![make_slide("s1", 0)]);
287 pres.audio_track = None;
288 pres.transcript = Some(Transcript {
289 mode: TranscriptMode::Synchronized,
290 language: "it-IT".to_string(),
291 segments: vec![],
292 });
293 let errors = validate(&pres);
294 assert!(errors
295 .iter()
296 .any(|e| matches!(e, VecslideError::EmptyAudioTrack)));
297 }
298}