1use anyhow::{Context, Result};
2use sqlx::{Connection, PgConnection};
3use tracing::log::warn;
4use uuid::Uuid;
5
6use headless_lms_models::{
7 PKeyPolicy, certificate_configuration_to_requirements,
8 certificate_configurations::{self, DatabaseCertificateConfiguration},
9 chatbot_configurations::{self, NewChatbotConf},
10 course_instances::{self, CourseInstance, NewCourseInstance},
11 course_language_groups,
12 course_modules::CourseModule,
13 courses::{self, Course, NewCourse},
14 file_uploads, glossary,
15 pages::{self, NewCoursePage, NewPage},
16 roles::{self, RoleDomain, UserRole},
17};
18use headless_lms_utils::document_schema_processor::GutenbergBlock;
19
20use crate::programs::seed::{
21 builder::{context::SeedContext, module::ModuleBuilder},
22 seed_helpers::get_seed_spec_fetcher,
23};
24
25#[derive(Debug, Clone)]
27pub struct CourseInstanceConfig {
28 pub name: Option<String>,
29 pub description: Option<String>,
30 pub support_email: Option<String>,
31 pub teacher_in_charge_name: String,
32 pub teacher_in_charge_email: String,
33 pub opening_time: Option<chrono::DateTime<chrono::Utc>>,
34 pub closing_time: Option<chrono::DateTime<chrono::Utc>>,
35 pub instance_id: Option<Uuid>,
36}
37
38#[derive(Debug, Clone)]
40pub struct PageConfig {
41 pub url: String,
42 pub title: String,
43 pub page_number: i32,
44 pub is_hidden: bool,
45 pub content: Option<Vec<headless_lms_utils::document_schema_processor::GutenbergBlock>>,
46}
47
48#[derive(Debug, Clone)]
50pub struct GlossaryEntry {
51 pub acronym: String,
52 pub definition: String,
53}
54
55#[derive(Debug, Clone)]
57pub struct CertificateConfig {
58 pub background_svg_path: String,
59 pub certificate_id: Option<Uuid>,
60}
61
62#[derive(Debug, Clone)]
64pub struct CourseBuilder {
65 pub name: String,
66 pub slug: String,
67 pub language_code: String,
68 pub can_add_chatbot: bool,
69 pub description: String,
70 pub default_instance_name: &'static [u8],
71 pub modules: Vec<ModuleBuilder>,
72 pub extra_roles: Vec<(Uuid, UserRole)>,
73 pub course_id: Option<Uuid>,
74 pub instances: Vec<CourseInstanceConfig>,
75 pub chatbot_configs: Vec<NewChatbotConf>,
76 pub pages: Vec<PageConfig>,
77 pub glossary_entries: Vec<GlossaryEntry>,
78 pub certificate_config: Option<CertificateConfig>,
79 pub front_page_content: Option<Vec<GutenbergBlock>>,
80 pub chapter_locking_enabled: bool,
81 pub flagged_answers_skip_manual_review_and_allow_retry: bool,
82 pub flagged_answers_threshold: Option<i32>,
83}
84
85impl CourseBuilder {
86 pub fn new(name: impl Into<String>, slug: impl Into<String>) -> Self {
87 Self {
88 name: name.into(),
89 slug: slug.into(),
90 language_code: "en".into(),
91 can_add_chatbot: false,
92 description: "Sample course.".into(),
93 default_instance_name: b"default-instance",
94 modules: vec![],
95 extra_roles: vec![],
96 course_id: None,
97 instances: vec![],
98 chatbot_configs: vec![],
99 pages: vec![],
100 glossary_entries: vec![],
101 certificate_config: None,
102 front_page_content: None,
103 chapter_locking_enabled: false,
104 flagged_answers_skip_manual_review_and_allow_retry: false,
105 flagged_answers_threshold: None,
106 }
107 }
108
109 pub fn flagged_answers_skip_manual_review_and_allow_retry(mut self, v: bool) -> Self {
111 self.flagged_answers_skip_manual_review_and_allow_retry = v;
112 self
113 }
114
115 pub fn flagged_answers_threshold(mut self, v: Option<i32>) -> Self {
117 self.flagged_answers_threshold = v;
118 self
119 }
120
121 pub fn chatbot(mut self, v: bool) -> Self {
122 self.can_add_chatbot = v;
123 self
124 }
125 pub fn chapter_locking_enabled(mut self, v: bool) -> Self {
126 self.chapter_locking_enabled = v;
127 self
128 }
129 pub fn desc(mut self, d: impl Into<String>) -> Self {
130 self.description = d.into();
131 self
132 }
133 pub fn language(mut self, lc: impl Into<String>) -> Self {
134 self.language_code = lc.into();
135 self
136 }
137 pub fn module(mut self, m: ModuleBuilder) -> Self {
138 self.modules.push(m);
139 self
140 }
141 pub fn modules<I: IntoIterator<Item = ModuleBuilder>>(mut self, it: I) -> Self {
142 self.modules.extend(it);
143 self
144 }
145 pub fn role(mut self, user_id: Uuid, role: UserRole) -> Self {
146 self.extra_roles.push((user_id, role));
147 self
148 }
149 pub fn course_id(mut self, id: Uuid) -> Self {
150 self.course_id = Some(id);
151 self
152 }
153
154 pub fn instance(mut self, config: CourseInstanceConfig) -> Self {
155 self.instances.push(config);
156 self
157 }
158
159 pub fn chatbot_config(mut self, config: NewChatbotConf) -> Self {
160 if !self.can_add_chatbot {
161 warn!("Can't add chatbot to this course!!!!");
162 return self;
163 }
164 self.chatbot_configs.push(config);
165 self
166 }
167
168 pub fn top_level_page(
170 mut self,
171 url: impl Into<String>,
172 title: impl Into<String>,
173 page_number: i32,
174 is_hidden: bool,
175 content: Option<Vec<headless_lms_utils::document_schema_processor::GutenbergBlock>>,
176 ) -> Self {
177 self.pages.push(PageConfig {
178 url: url.into(),
179 title: title.into(),
180 page_number,
181 is_hidden,
182 content,
183 });
184 self
185 }
186
187 pub fn glossary_entry(
188 mut self,
189 acronym: impl Into<String>,
190 definition: impl Into<String>,
191 ) -> Self {
192 self.glossary_entries.push(GlossaryEntry {
193 acronym: acronym.into(),
194 definition: definition.into(),
195 });
196 self
197 }
198
199 pub fn certificate_config(
200 mut self,
201 background_svg_path: impl Into<String>,
202 certificate_id: Option<Uuid>,
203 ) -> Self {
204 self.certificate_config = Some(CertificateConfig {
205 background_svg_path: background_svg_path.into(),
206 certificate_id,
207 });
208 self
209 }
210
211 pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
213 self.front_page_content = Some(content);
214 self
215 }
216
217 pub async fn seed(
219 self,
220 conn: &mut PgConnection,
221 cx: &SeedContext,
222 ) -> Result<(Course, CourseInstance, CourseModule)> {
223 let course_id = self.course_id.unwrap_or(cx.base_course_ns);
224
225 let new_course = NewCourse {
226 name: self.name.clone(),
227 organization_id: cx.org,
228 slug: self.slug.clone(),
229 language_code: self.language_code.clone(),
230 teacher_in_charge_name: "admin".into(),
231 teacher_in_charge_email: "admin@example.com".into(),
232 description: self.description.clone(),
233 is_draft: false,
234 is_test_mode: false,
235 is_unlisted: false,
236 copy_user_permissions: false,
237 is_joinable_by_code_only: false,
238 join_code: None,
239 ask_marketing_consent: false,
240 flagged_answers_threshold: Some(self.flagged_answers_threshold.unwrap_or(3)),
241 can_add_chatbot: self.can_add_chatbot,
242 };
243
244 let mut tx = conn.begin().await.context("starting transaction")?;
246
247 let course_language_group_id = course_language_groups::insert(
248 &mut tx,
249 headless_lms_models::PKeyPolicy::Generate,
250 &new_course.slug,
251 )
252 .await?;
253
254 let course_id = courses::insert(
255 &mut tx,
256 headless_lms_models::PKeyPolicy::Fixed(course_id),
257 course_language_group_id,
258 &new_course,
259 )
260 .await
261 .context("inserting course")?;
262
263 let course = courses::get_course(&mut tx, course_id)
264 .await
265 .context("getting course")?;
266
267 for chatbot_conf in self.chatbot_configs {
268 let chatbotconf_id = chatbot_conf.chatbotconf_id.unwrap_or_else(Uuid::new_v4);
269 chatbot_configurations::insert(
270 &mut tx,
271 PKeyPolicy::Fixed(chatbotconf_id),
272 NewChatbotConf {
273 course_id: Some(course_id),
274 ..chatbot_conf
275 },
276 )
277 .await
278 .context("inserting chatbot configuration for course")?;
279 }
280
281 let mut last_module = None;
282
283 for (i, m) in self.modules.into_iter().enumerate() {
284 last_module = Some(m.seed(&mut tx, cx, course.id, i as i32).await?);
285 }
286
287 for (user_id, role) in self.extra_roles {
288 roles::insert(&mut tx, user_id, role, RoleDomain::Course(course.id))
289 .await
290 .context("inserting course role")?;
291 }
292
293 let mut default_instance = None;
295 for instance_config in self.instances {
296 let instance_id = instance_config.instance_id.unwrap_or_else(Uuid::new_v4);
297 let instance = course_instances::insert(
298 &mut tx,
299 PKeyPolicy::Fixed(instance_id),
300 NewCourseInstance {
301 course_id: course.id,
302 name: instance_config.name.as_deref(),
303 description: instance_config.description.as_deref(),
304 support_email: instance_config.support_email.as_deref(),
305 teacher_in_charge_name: &instance_config.teacher_in_charge_name,
306 teacher_in_charge_email: &instance_config.teacher_in_charge_email,
307 opening_time: instance_config.opening_time,
308 closing_time: instance_config.closing_time,
309 },
310 )
311 .await
312 .context("inserting course instance")?;
313
314 if default_instance.is_none() {
316 default_instance = Some(instance);
317 }
318 }
319
320 let default_instance =
321 default_instance.expect("At least one course instance must be provided");
322
323 let default_front_page_content = vec![
325 GutenbergBlock::landing_page_hero_section("Welcome to...", "Subheading"),
326 GutenbergBlock::landing_page_copy_text(
327 "About this course",
328 "This course teaches you xxx.",
329 ),
330 GutenbergBlock::course_objective_section(),
331 GutenbergBlock::empty_block_from_name("moocfi/course-chapter-grid".to_string()),
332 GutenbergBlock::empty_block_from_name("moocfi/top-level-pages".to_string()),
333 GutenbergBlock::empty_block_from_name("moocfi/congratulations".to_string()),
334 GutenbergBlock::empty_block_from_name("moocfi/course-progress".to_string()),
335 ];
336
337 let front_page_blocks = self
338 .front_page_content
339 .unwrap_or(default_front_page_content);
340
341 let course_front_page = NewPage {
342 chapter_id: None,
343 content: front_page_blocks,
344 course_id: Some(course.id),
345 exam_id: None,
346 front_page_of_chapter_id: None,
347 title: course.name.clone(),
348 url_path: String::from("/"),
349 exercises: vec![],
350 exercise_slides: vec![],
351 exercise_tasks: vec![],
352 content_search_language: None,
353 hidden: false,
354 };
355
356 pages::insert_page(
357 &mut tx,
358 course_front_page,
359 cx.teacher,
360 get_seed_spec_fetcher(),
361 crate::domain::models_requests::fetch_service_info,
362 )
363 .await
364 .context("inserting course front page")?;
365
366 for page_config in self.pages {
368 let mut course_page = NewCoursePage::new(
369 course.id,
370 page_config.page_number,
371 &page_config.url,
372 &page_config.title,
373 );
374
375 if page_config.is_hidden {
376 course_page = course_page.set_hidden(true);
377 }
378
379 if let Some(content) = page_config.content {
380 course_page = course_page.set_content(content);
381 }
382
383 pages::insert_course_page(&mut tx, &course_page, cx.teacher)
384 .await
385 .context("inserting course page")?;
386 }
387
388 for glossary_entry in self.glossary_entries {
390 glossary::insert(
391 &mut tx,
392 &glossary_entry.acronym,
393 &glossary_entry.definition,
394 course.id,
395 )
396 .await
397 .context("inserting glossary entry")?;
398 }
399
400 if let Some(cert_config) = self.certificate_config {
402 let background_svg_file_upload_id = file_uploads::insert(
403 &mut tx,
404 &format!(
405 "background-{}.svg",
406 last_module
407 .as_ref()
408 .expect("At least one module must be provided")
409 .id
410 ),
411 &cert_config.background_svg_path,
412 "image/svg+xml",
413 None,
414 )
415 .await
416 .context("inserting certificate background file")?;
417
418 let certificate_id = cert_config
419 .certificate_id
420 .unwrap_or_else(|| cx.v5(b"886d3e22-5007-4371-94d7-e0ad93a2391c"));
421 let configuration = DatabaseCertificateConfiguration {
422 id: certificate_id,
423 certificate_owner_name_y_pos: None,
424 certificate_owner_name_x_pos: None,
425 certificate_owner_name_font_size: None,
426 certificate_owner_name_text_color: None,
427 certificate_owner_name_text_anchor: None,
428 certificate_validate_url_y_pos: None,
429 certificate_validate_url_x_pos: None,
430 certificate_validate_url_font_size: None,
431 certificate_validate_url_text_color: None,
432 certificate_validate_url_text_anchor: None,
433 certificate_date_y_pos: None,
434 certificate_date_x_pos: None,
435 certificate_date_font_size: None,
436 certificate_date_text_color: None,
437 certificate_date_text_anchor: None,
438 certificate_locale: None,
439 paper_size: None,
440 background_svg_path: cert_config.background_svg_path,
441 background_svg_file_upload_id,
442 overlay_svg_path: None,
443 overlay_svg_file_upload_id: None,
444 render_certificate_grade: false,
445 certificate_grade_y_pos: None,
446 certificate_grade_x_pos: None,
447 certificate_grade_font_size: None,
448 certificate_grade_text_color: None,
449 certificate_grade_text_anchor: None,
450 };
451 let database_configuration =
452 certificate_configurations::insert(&mut tx, &configuration)
453 .await
454 .context("inserting certificate configuration")?;
455 certificate_configuration_to_requirements::insert(
456 &mut tx,
457 database_configuration.id,
458 Some(
459 last_module
460 .as_ref()
461 .expect("At least one module must be provided")
462 .id,
463 ),
464 )
465 .await
466 .context("linking certificate configuration to requirements")?;
467 }
468 tx.commit().await.context("committing transaction")?;
469
470 courses::set_cheater_detection_enabled(conn, course.id, false)
473 .await
474 .context("disabling cheater detection")?;
475
476 if self.chapter_locking_enabled || self.flagged_answers_skip_manual_review_and_allow_retry {
477 use headless_lms_models::courses::CourseUpdate;
478 let course_update = CourseUpdate {
479 name: course.name.clone(),
480 description: course.description.clone(),
481 is_draft: course.is_draft,
482 is_test_mode: course.is_test_mode,
483 can_add_chatbot: course.can_add_chatbot,
484 is_unlisted: course.is_unlisted,
485 is_joinable_by_code_only: course.is_joinable_by_code_only,
486 ask_marketing_consent: course.ask_marketing_consent,
487 flagged_answers_threshold: self.flagged_answers_threshold.unwrap_or(3),
488 flagged_answers_skip_manual_review_and_allow_retry: self
489 .flagged_answers_skip_manual_review_and_allow_retry,
490 closed_at: course.closed_at,
491 closed_additional_message: course.closed_additional_message.clone(),
492 closed_course_successor_id: course.closed_course_successor_id,
493 chapter_locking_enabled: self.chapter_locking_enabled,
494 ai_policy: course.ai_policy,
495 course_material_ai_instructions: course.course_material_ai_instructions,
496 };
497 let updated_course = courses::update_course(conn, course.id, course_update)
498 .await
499 .context("updating course")?;
500 return Ok((
501 updated_course,
502 default_instance,
503 last_module.expect("At least one module must be provided"),
504 ));
505 }
506
507 Ok((
508 course,
509 default_instance,
510 last_module.expect("At least one module must be provided"),
511 ))
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use crate::programs::seed::builder::module::ModuleBuilder;
518
519 use super::*;
520 use headless_lms_models::roles::UserRole;
521
522 #[test]
523 fn course_builder_new() {
524 let course = CourseBuilder::new("Test Course", "test-course");
525
526 assert_eq!(course.name, "Test Course");
527 assert_eq!(course.slug, "test-course");
528 assert_eq!(course.language_code, "en");
529 assert!(!course.can_add_chatbot);
530 assert_eq!(course.description, "Sample course.");
531 assert_eq!(course.default_instance_name, b"default-instance");
532 assert!(course.modules.is_empty());
533 assert!(course.extra_roles.is_empty());
534 assert!(course.course_id.is_none());
535 }
536
537 #[test]
538 fn course_builder_string_conversion() {
539 let course1 = CourseBuilder::new("String literal", "string-slug");
540 let course2 = CourseBuilder::new(String::from("Owned string"), String::from("owned-slug"));
541
542 assert_eq!(course1.name, "String literal");
543 assert_eq!(course1.slug, "string-slug");
544 assert_eq!(course2.name, "Owned string");
545 assert_eq!(course2.slug, "owned-slug");
546 }
547
548 #[test]
549 fn course_builder_chatbot() {
550 let course = CourseBuilder::new("Test Course", "test-course").chatbot(true);
551 assert!(course.can_add_chatbot);
552
553 let course_disabled = CourseBuilder::new("Test Course", "test-course").chatbot(false);
554 assert!(!course_disabled.can_add_chatbot);
555 }
556
557 #[test]
558 fn course_builder_desc() {
559 let course =
560 CourseBuilder::new("Test Course", "test-course").desc("A comprehensive test course");
561
562 assert_eq!(course.description, "A comprehensive test course");
563
564 let desc_string = String::from("Another description");
565 let course2 = CourseBuilder::new("Test Course", "test-course").desc(desc_string);
566 assert_eq!(course2.description, "Another description");
567 }
568
569 #[test]
570 fn course_builder_language() {
571 let course = CourseBuilder::new("Test Course", "test-course").language("fi-FI");
572
573 assert_eq!(course.language_code, "fi-FI");
574
575 let lang_string = String::from("sv-SE");
576 let course2 = CourseBuilder::new("Test Course", "test-course").language(lang_string);
577 assert_eq!(course2.language_code, "sv-SE");
578 }
579
580 #[test]
581 fn course_builder_module() {
582 let module = ModuleBuilder::new().order(0);
583 let course = CourseBuilder::new("Test Course", "test-course").module(module);
584
585 assert_eq!(course.modules.len(), 1);
586 assert_eq!(course.modules[0].order, Some(0));
587 }
588
589 #[test]
590 fn course_builder_multiple_modules() {
591 let module1 = ModuleBuilder::new().order(0);
592 let module2 = ModuleBuilder::new().order(1);
593 let course = CourseBuilder::new("Test Course", "test-course")
594 .module(module1)
595 .module(module2);
596
597 assert_eq!(course.modules.len(), 2);
598 assert_eq!(course.modules[0].order, Some(0));
599 assert_eq!(course.modules[1].order, Some(1));
600 }
601
602 #[test]
603 fn course_builder_role() {
604 let user_id = Uuid::new_v4();
605 let course =
606 CourseBuilder::new("Test Course", "test-course").role(user_id, UserRole::Teacher);
607
608 assert_eq!(course.extra_roles.len(), 1);
609 assert_eq!(course.extra_roles[0].0, user_id);
610 assert_eq!(course.extra_roles[0].1, UserRole::Teacher);
611 }
612
613 #[test]
614 fn course_builder_multiple_roles() {
615 let user1_id = Uuid::new_v4();
616 let user2_id = Uuid::new_v4();
617 let course = CourseBuilder::new("Test Course", "test-course")
618 .role(user1_id, UserRole::Teacher)
619 .role(user2_id, UserRole::MaterialViewer);
620
621 assert_eq!(course.extra_roles.len(), 2);
622 assert_eq!(course.extra_roles[0].0, user1_id);
623 assert_eq!(course.extra_roles[0].1, UserRole::Teacher);
624 assert_eq!(course.extra_roles[1].0, user2_id);
625 assert_eq!(course.extra_roles[1].1, UserRole::MaterialViewer);
626 }
627
628 #[test]
629 fn course_builder_course_id() {
630 let course_id = Uuid::new_v4();
631 let course = CourseBuilder::new("Test Course", "test-course").course_id(course_id);
632
633 assert_eq!(course.course_id, Some(course_id));
634 }
635
636 #[test]
637 fn course_builder_fluent_interface() {
638 let user_id = Uuid::new_v4();
639 let course_id = Uuid::new_v4();
640 let module = ModuleBuilder::new().order(0);
641
642 let course = CourseBuilder::new("Advanced Course", "advanced-course")
643 .desc("A comprehensive advanced course")
644 .language("fi-FI")
645 .chatbot(true)
646 .module(module)
647 .role(user_id, UserRole::Teacher)
648 .course_id(course_id);
649
650 assert_eq!(course.name, "Advanced Course");
651 assert_eq!(course.slug, "advanced-course");
652 assert_eq!(course.description, "A comprehensive advanced course");
653 assert_eq!(course.language_code, "fi-FI");
654 assert!(course.can_add_chatbot);
655 assert_eq!(course.modules.len(), 1);
656 assert_eq!(course.extra_roles.len(), 1);
657 assert_eq!(course.course_id, Some(course_id));
658 }
659
660 #[test]
661 fn course_builder_default_values() {
662 let course = CourseBuilder::new("Minimal Course", "minimal");
663
664 assert_eq!(course.language_code, "en");
665 assert!(!course.can_add_chatbot);
666 assert_eq!(course.description, "Sample course.");
667 assert_eq!(course.default_instance_name, b"default-instance");
668 assert!(course.modules.is_empty());
669 assert!(course.extra_roles.is_empty());
670 assert!(course.course_id.is_none());
671 }
672
673 #[test]
674 fn course_builder_empty_strings() {
675 let course = CourseBuilder::new("", "");
676
677 assert_eq!(course.name, "");
678 assert_eq!(course.slug, "");
679 assert_eq!(course.language_code, "en");
680 assert_eq!(course.description, "Sample course.");
681 }
682
683 #[test]
684 fn course_builder_method_chaining_order() {
685 let course1 = CourseBuilder::new("Course 1", "course-1")
686 .chatbot(true)
687 .desc("Description 1")
688 .language("fi-FI");
689
690 let course2 = CourseBuilder::new("Course 2", "course-2")
691 .language("fi-FI")
692 .desc("Description 2")
693 .chatbot(true);
694
695 assert_eq!(course1.can_add_chatbot, course2.can_add_chatbot);
696 assert_eq!(course1.language_code, course2.language_code);
697 assert_eq!(course1.description, "Description 1");
698 assert_eq!(course2.description, "Description 2");
699 }
700
701 #[test]
702 fn course_builder_front_page_content() {
703 let custom_content = vec![
704 GutenbergBlock::landing_page_hero_section("Custom Welcome", "Custom Subheading"),
705 GutenbergBlock::course_objective_section(),
706 ];
707
708 let course = CourseBuilder::new("Test Course", "test-course")
709 .front_page_content(custom_content.clone());
710
711 assert_eq!(course.front_page_content, Some(custom_content));
712 }
713
714 #[test]
715 fn course_builder_front_page_content_default() {
716 let course = CourseBuilder::new("Test Course", "test-course");
717
718 assert_eq!(course.front_page_content, None);
719 }
720
721 #[test]
722 fn course_builder_top_level_page() {
723 let course = CourseBuilder::new("Test Course", "test-course")
724 .top_level_page("/welcome", "Welcome Page", 1, false, None)
725 .top_level_page("/hidden", "Hidden Page", 2, true, None);
726
727 assert_eq!(course.pages.len(), 2);
728 assert_eq!(course.pages[0].url, "/welcome");
729 assert_eq!(course.pages[0].title, "Welcome Page");
730 assert_eq!(course.pages[0].page_number, 1);
731 assert!(!course.pages[0].is_hidden);
732 assert_eq!(course.pages[0].content, None);
733
734 assert_eq!(course.pages[1].url, "/hidden");
735 assert_eq!(course.pages[1].title, "Hidden Page");
736 assert_eq!(course.pages[1].page_number, 2);
737 assert!(course.pages[1].is_hidden);
738 assert_eq!(course.pages[1].content, None);
739 }
740
741 #[test]
742 fn course_builder_top_level_page_with_content() {
743 let content = vec![];
744 let course = CourseBuilder::new("Test Course", "test-course").top_level_page(
745 "/content",
746 "Content Page",
747 1,
748 false,
749 Some(content.clone()),
750 );
751
752 assert_eq!(course.pages.len(), 1);
753 assert_eq!(course.pages[0].url, "/content");
754 assert_eq!(course.pages[0].title, "Content Page");
755 assert_eq!(course.pages[0].page_number, 1);
756 assert!(!course.pages[0].is_hidden);
757 assert_eq!(course.pages[0].content, Some(content));
758 }
759
760 #[test]
761 fn course_builder_multiple_top_level_pages() {
762 let course = CourseBuilder::new("Test Course", "test-course")
763 .top_level_page("/welcome", "Welcome", 1, false, None)
764 .top_level_page("/about", "About", 2, false, None)
765 .top_level_page("/secret", "Secret", 3, true, None);
766
767 assert_eq!(course.pages.len(), 3);
768 assert_eq!(course.pages[0].url, "/welcome");
769 assert_eq!(course.pages[1].url, "/about");
770 assert_eq!(course.pages[2].url, "/secret");
771 assert!(course.pages[2].is_hidden);
772 }
773}