Skip to main content

headless_lms_server/programs/seed/builder/
course.rs

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/// Additional course instance configuration
26#[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/// Generic page configuration for top-level course pages
39#[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/// Glossary entry
49#[derive(Debug, Clone)]
50pub struct GlossaryEntry {
51    pub acronym: String,
52    pub definition: String,
53}
54
55/// Certificate configuration
56#[derive(Debug, Clone)]
57pub struct CertificateConfig {
58    pub background_svg_path: String,
59    pub certificate_id: Option<Uuid>,
60}
61
62/// Builder for courses with modules, chapters, pages, and exercises.
63#[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    /// Enables skipping manual review for flagged answers and allowing retry.
110    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    /// Sets the number of spam reports needed to trigger reset/manual review.
116    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    /// Add a top-level page to the course
169    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    /// Set custom content for the course front page. If not set, default content will be used.
212    pub fn front_page_content(mut self, content: Vec<GutenbergBlock>) -> Self {
213        self.front_page_content = Some(content);
214        self
215    }
216
217    /// Seeds the course and all nested content. Returns `(course, default_instance, last_module)`.
218    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        // Create course manually without default module
245        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        // Create course instances
294        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            // Use the first instance as the default instance for return value
315            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        // Create mandatory front page for the course
324        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        // Create top-level pages
367        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        // Add glossary entries
389        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        // Create certificate configuration
401        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        // Seeded courses are completed in seconds by system tests, which would flag every seeded
471        // user and break the suite, so disable cheater detection for builder-created courses.
472        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}