Skip to main content

headless_lms_utils/
document_schema_processor.rs

1use std::{
2    cell::RefCell,
3    collections::{HashMap, HashSet},
4    rc::Rc,
5};
6
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use utoipa::ToSchema;
11use uuid::Uuid;
12
13use crate::strings::strip_html_tags;
14
15/// The name of the block that embeds an exercise; its `id` attribute is the exercise id.
16pub const EXERCISE_BLOCK: &str = "moocfi/exercise";
17
18/// Blocks that are not allowed in top-level pages (pages without chapter_id).
19/// Note: This is NOT for chapter front pages. Chapter front pages can contain these blocks.
20const DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES: &[&str] = &[
21    EXERCISE_BLOCK,
22    "moocfi/exercise-task",
23    "moocfi/exercises-in-chapter",
24    "moocfi/pages-in-chapter",
25    "moocfi/chapter-progress",
26];
27
28/// The heading and description of each objective in [`GutenbergBlock::course_objective_section`],
29/// placeholder text for the teacher to replace.
30pub const COURSE_OBJECTIVE_PLACEHOLDERS: [(&str, &str); 3] = [
31    (
32        "Objective #1",
33        "Neque porro quisquam est qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit...",
34    ),
35    (
36        "Objective #2",
37        "There is no one who loves pain itself, who seeks after it and wants to have it, simply because it is pain...",
38    ),
39    (
40        "Objective #3",
41        "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas a tempor risus. Morbi at sapien.",
42    ),
43];
44
45/// The `style` attribute Gutenberg stores for centred text in blocks with the text-align support.
46fn centred_text_style() -> Value {
47    serde_json::json!({"typography": {"textAlign": "center"}})
48}
49
50pub use crate::attributes;
51use crate::prelude::*;
52
53#[macro_export]
54macro_rules! attributes {
55    () => {{
56        serde_json::Map::<String, serde_json::Value>::new()
57    }};
58    ($($name: tt: $value: expr_2021),+ $(,)*) => {{
59        let mut map = serde_json::Map::<String, serde_json::Value>::new();
60        $(map.insert($name.into(), serde_json::json!($value));)*
61        map
62    }};
63}
64
65#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, ToSchema)]
66
67pub struct GutenbergBlock {
68    #[serde(rename = "clientId")]
69    pub client_id: Uuid,
70    pub name: String,
71    #[serde(rename = "isValid")]
72    pub is_valid: bool,
73    pub attributes: Map<String, Value>,
74    #[serde(rename = "innerBlocks")]
75    #[schema(no_recursion)]
76    pub inner_blocks: Vec<GutenbergBlock>,
77}
78
79impl GutenbergBlock {
80    pub fn paragraph(paragraph: &str) -> Self {
81        Self::block_with_name_and_attributes(
82            "core/paragraph",
83            attributes! {
84              "content": paragraph.to_string(),
85              "dropCap": false
86            },
87        )
88    }
89
90    pub fn empty_block_from_name(name: String) -> Self {
91        GutenbergBlock {
92            client_id: Uuid::new_v4(),
93            name,
94            is_valid: true,
95            attributes: Map::new(),
96            inner_blocks: vec![],
97        }
98    }
99    pub fn block_with_name_and_attributes(name: &str, attributes: Map<String, Value>) -> Self {
100        GutenbergBlock {
101            client_id: Uuid::new_v4(),
102            name: name.to_string(),
103            is_valid: true,
104            attributes,
105            inner_blocks: vec![],
106        }
107    }
108    pub fn block_with_name_attributes_and_inner_blocks(
109        name: &str,
110        attributes: Map<String, Value>,
111        inner_blocks: Vec<GutenbergBlock>,
112    ) -> Self {
113        GutenbergBlock {
114            client_id: Uuid::new_v4(),
115            name: name.to_string(),
116            is_valid: true,
117            attributes,
118            inner_blocks,
119        }
120    }
121    pub fn hero_section(title: &str, sub_title: &str) -> Self {
122        GutenbergBlock::block_with_name_and_attributes(
123            "moocfi/hero-section",
124            attributes! {
125                "title": title,
126                "subtitle": sub_title
127            },
128        )
129    }
130    pub fn landing_page_hero_section(title: &str, sub_title: &str) -> Self {
131        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
132            "moocfi/landing-page-hero-section",
133            attributes! {"title": title},
134            vec![GutenbergBlock::block_with_name_and_attributes(
135                "core/paragraph",
136                attributes! {
137                    "content": sub_title,
138                    "dropCap": false,
139                    "placeholder": "Insert short description of course...",
140                    "style": centred_text_style()
141                },
142            )],
143        )
144    }
145
146    /// The course objectives section with [`COURSE_OBJECTIVE_PLACEHOLDERS`] for the teacher to
147    /// replace.
148    pub fn course_objective_section() -> Self {
149        let columns = COURSE_OBJECTIVE_PLACEHOLDERS
150            .iter()
151            .enumerate()
152            .map(|(index, (heading, description))| {
153                GutenbergBlock::block_with_name_attributes_and_inner_blocks(
154                    "core/column",
155                    attributes! {},
156                    vec![
157                        GutenbergBlock::block_with_name_and_attributes(
158                            "core/heading",
159                            attributes! {
160                                "level": 3,
161                                "content": heading,
162                                "anchor": format!("objective-{}", index + 1),
163                                "style": centred_text_style(),
164                            },
165                        ),
166                        GutenbergBlock::block_with_name_and_attributes(
167                            "core/paragraph",
168                            attributes! {
169                                "dropCap": false,
170                                "content": description,
171                                "style": centred_text_style(),
172                            },
173                        ),
174                    ],
175                )
176            })
177            .collect();
178        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
179            "moocfi/course-objective-section",
180            attributes! {
181                "title": "In this course you'll..."
182            },
183            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
184                "core/columns",
185                attributes! {
186                    "isStackedOnMobile": true
187                },
188                columns,
189            )],
190        )
191    }
192
193    pub fn landing_page_copy_text(heading: &str, content: &str) -> Self {
194        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
195            "moocfi/landing-page-copy-text",
196            attributes! {},
197            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
198                "core/columns",
199                attributes! {
200                    "isStackedOnMobile": true
201                },
202                vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
203                    "core/column",
204                    attributes! {},
205                    vec![
206                        GutenbergBlock::block_with_name_and_attributes(
207                            "core/heading",
208                            attributes! {
209                                "content": heading,
210                                "level": 2,
211                                "placeholder": heading,
212                                "anchor": heading.to_lowercase().replace(' ', "-"),
213                            },
214                        ),
215                        GutenbergBlock::block_with_name_and_attributes(
216                            "core/paragraph",
217                            attributes! {
218                                "content": content,
219                                "dropCap": false
220                            },
221                        ),
222                    ],
223                )],
224            )],
225        )
226    }
227
228    pub fn with_id(self, id: Uuid) -> Self {
229        Self {
230            client_id: id,
231            ..self
232        }
233    }
234
235    /// The exercise an exercise block embeds; `None` for other blocks and for an exercise block
236    /// whose `id` attribute names none.
237    pub fn exercise_id(&self) -> Option<Uuid> {
238        if self.name != EXERCISE_BLOCK {
239            return None;
240        }
241        self.attributes
242            .get("id")
243            .and_then(Value::as_str)
244            .and_then(|id| Uuid::parse_str(id).ok())
245    }
246}
247
248/// Depth-first, pre-order visit of every block with its index path (`[2, 0]` is the first child of
249/// the third top-level block).
250pub fn walk<'a>(blocks: &'a [GutenbergBlock], visit: &mut dyn FnMut(&'a GutenbergBlock, &[usize])) {
251    fn go<'a>(
252        blocks: &'a [GutenbergBlock],
253        prefix: &mut Vec<usize>,
254        visit: &mut dyn FnMut(&'a GutenbergBlock, &[usize]),
255    ) {
256        for (index, block) in blocks.iter().enumerate() {
257            prefix.push(index);
258            visit(block, prefix);
259            go(&block.inner_blocks, prefix, visit);
260            prefix.pop();
261        }
262    }
263    go(blocks, &mut Vec::new(), visit);
264}
265
266/// Depth-first, pre-order visit of every block.
267pub fn walk_mut(blocks: &mut [GutenbergBlock], visit: &mut dyn FnMut(&mut GutenbergBlock)) {
268    for block in blocks.iter_mut() {
269        visit(block);
270        walk_mut(&mut block.inner_blocks, visit);
271    }
272}
273
274/// The `clientId` of every block in the tree, in [`walk`] order.
275pub fn all_client_ids(blocks: &[GutenbergBlock]) -> Vec<Uuid> {
276    let mut ids = Vec::new();
277    walk(blocks, &mut |block, _| ids.push(block.client_id));
278    ids
279}
280
281/// The blocks, at any depth, that aren't allowed in top-level pages (pages without chapter_id).
282/// Note: This is NOT for chapter front pages. Chapter front pages can contain these blocks.
283pub fn blocks_not_allowed_in_top_level_pages(input: &[GutenbergBlock]) -> Vec<&GutenbergBlock> {
284    let mut found = Vec::new();
285    walk(input, &mut |block, _| {
286        if DISALLOWED_BLOCKS_IN_TOP_LEVEL_PAGES.contains(&block.name.as_str()) {
287            found.push(block);
288        }
289    });
290    found
291}
292
293/// Checks if blocks, at any depth, contain any that are not allowed in top-level pages (pages
294/// without chapter_id). See [`blocks_not_allowed_in_top_level_pages`].
295pub fn contains_blocks_not_allowed_in_top_level_pages(input: &[GutenbergBlock]) -> bool {
296    !blocks_not_allowed_in_top_level_pages(input).is_empty()
297}
298
299pub fn remap_ids_in_content(
300    content: &serde_json::Value,
301    chaged_ids: HashMap<Uuid, Uuid>,
302) -> UtilResult<serde_json::Value> {
303    // naive implementation for now because the structure of the content was not decided at the time of writing this.
304    // In the future we could only edit the necessary fields.
305    let mut content_str = serde_json::to_string(content)?;
306    for (k, v) in chaged_ids.into_iter() {
307        content_str = content_str.replace(&k.to_string(), &v.to_string());
308    }
309    Ok(serde_json::from_str(&content_str)?)
310}
311
312/** Removes the private spec from exercise tasks. */
313pub fn remove_sensitive_attributes(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
314    input
315        .into_iter()
316        .map(|mut block| {
317            if block.name == "moocfi/exercise-task" {
318                block.attributes = Map::new();
319            }
320            block.inner_blocks = remove_sensitive_attributes(block.inner_blocks);
321            block
322        })
323        .collect()
324}
325
326/// Filters lock-chapter blocks' inner blocks based on whether the chapter is locked.
327/// If the chapter is not locked, inner blocks are removed to prevent unauthorized access.
328/// This function recursively processes all blocks to handle nested structures.
329pub fn filter_lock_chapter_blocks(
330    input: Vec<GutenbergBlock>,
331    is_locked: bool,
332) -> Vec<GutenbergBlock> {
333    input
334        .into_iter()
335        .map(|mut block| {
336            if block.name == "moocfi/lock-chapter" {
337                if !is_locked {
338                    // Remove inner blocks if chapter is not locked
339                    block.inner_blocks = vec![];
340                } else {
341                    // Recursively process inner blocks if locked
342                    block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
343                }
344            } else {
345                // Recursively process all blocks
346                block.inner_blocks = filter_lock_chapter_blocks(block.inner_blocks, is_locked);
347            }
348            block
349        })
350        .collect()
351}
352
353/// Replaces duplicate client IDs with new unique IDs in Gutenberg blocks.
354pub fn replace_duplicate_client_ids(input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
355    let seen_ids = Rc::new(RefCell::new(HashSet::new()));
356
357    replace_duplicate_client_ids_inner(input, seen_ids)
358}
359
360fn replace_duplicate_client_ids_inner(
361    mut input: Vec<GutenbergBlock>,
362    seen_ids: Rc<RefCell<HashSet<Uuid>>>,
363) -> Vec<GutenbergBlock> {
364    for block in input.iter_mut() {
365        let mut seen_ids_borrow = seen_ids.borrow_mut();
366        if seen_ids_borrow.contains(&block.client_id) {
367            block.client_id = Uuid::new_v4();
368        } else {
369            seen_ids_borrow.insert(block.client_id);
370        }
371        drop(seen_ids_borrow); // Release the borrow before recursive call
372
373        block.inner_blocks =
374            replace_duplicate_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone());
375    }
376    input
377}
378
379/// Gives repeated heading anchors a `-2`, `-3`, ... suffix so that every heading on the page renders
380/// a unique `id`. Duplicating a heading block copies its anchor, which is the usual way repeats end
381/// up on a page.
382///
383/// The first heading using an anchor, in document order, keeps it, so links to the original stay
384/// valid. A suffix is never one that another heading already uses. Anchors are compared trimmed,
385/// as the material renders them.
386pub fn suffix_duplicate_heading_anchors(mut input: Vec<GutenbergBlock>) -> Vec<GutenbergBlock> {
387    let mut taken = HashSet::new();
388    collect_heading_anchors(&input, &mut taken);
389    let mut seen = HashSet::new();
390    suffix_duplicate_heading_anchors_inner(&mut input, &mut seen, &mut taken);
391    input
392}
393
394fn heading_anchor(block: &GutenbergBlock) -> Option<String> {
395    if block.name != "core/heading" {
396        return None;
397    }
398    let anchor = block.attributes.get("anchor")?.as_str()?.trim();
399    (!anchor.is_empty()).then(|| anchor.to_string())
400}
401
402fn collect_heading_anchors(blocks: &[GutenbergBlock], anchors: &mut HashSet<String>) {
403    for block in blocks {
404        if let Some(anchor) = heading_anchor(block) {
405            anchors.insert(anchor);
406        }
407        collect_heading_anchors(&block.inner_blocks, anchors);
408    }
409}
410
411fn suffix_duplicate_heading_anchors_inner(
412    blocks: &mut [GutenbergBlock],
413    seen: &mut HashSet<String>,
414    taken: &mut HashSet<String>,
415) {
416    for block in blocks.iter_mut() {
417        if let Some(anchor) = heading_anchor(block)
418            && !seen.insert(anchor.clone())
419        {
420            let mut suffix = 2;
421            let mut suffixed = format!("{anchor}-{suffix}");
422            while taken.contains(&suffixed) {
423                suffix += 1;
424                suffixed = format!("{anchor}-{suffix}");
425            }
426            taken.insert(suffixed.clone());
427            block
428                .attributes
429                .insert("anchor".to_string(), Value::String(suffixed));
430        }
431        suffix_duplicate_heading_anchors_inner(&mut block.inner_blocks, seen, taken);
432    }
433}
434
435/// Validates that all client IDs in the Gutenberg blocks are unique.
436/// Returns an error if duplicate client IDs are found.
437pub fn validate_unique_client_ids(input: Vec<GutenbergBlock>) -> UtilResult<Vec<GutenbergBlock>> {
438    let seen_ids = Rc::new(RefCell::new(HashSet::new()));
439
440    validate_unique_client_ids_inner(input, seen_ids)
441}
442
443fn validate_unique_client_ids_inner(
444    input: Vec<GutenbergBlock>,
445    seen_ids: Rc<RefCell<HashSet<Uuid>>>,
446) -> UtilResult<Vec<GutenbergBlock>> {
447    for block in input.iter() {
448        let mut seen_ids_borrow = seen_ids.borrow_mut();
449        if seen_ids_borrow.contains(&block.client_id) {
450            return Err(UtilError::new(
451                UtilErrorType::Other,
452                format!("Duplicate client ID found: {}", block.client_id),
453                None,
454            ));
455        } else {
456            seen_ids_borrow.insert(block.client_id);
457        }
458        drop(seen_ids_borrow); // Release the borrow before recursive call
459
460        validate_unique_client_ids_inner(block.inner_blocks.clone(), seen_ids.clone())?;
461    }
462    Ok(input)
463}
464
465/// The text a block and everything nested inside it carries, one entry per block that has any.
466///
467/// Blocks hold their text as HTML in a `content` attribute, which is stripped here: callers render
468/// this as plain text, and the markup is several times the size of the words in it. A block with
469/// no text of its own (a layout wrapper, an image) contributes only what its children do.
470fn blocks_text_content(blocks: &[GutenbergBlock], texts: &mut Vec<String>) {
471    for block in blocks {
472        if let Some(content) = block.attributes.get("content").and_then(Value::as_str) {
473            let text = strip_html_tags(content);
474            let text = text.trim();
475            if !text.is_empty() {
476                texts.push(text.to_string());
477            }
478        }
479        blocks_text_content(&block.inner_blocks, texts);
480    }
481}
482
483/// The learning objectives declared anywhere in `blocks`, one per line, or `None` when the page
484/// declares none.
485///
486/// Objectives are authored as list items inside a learning-objectives block, so the lines are the
487/// objectives themselves. Whether they cover the page, the chapter or the course depends on which
488/// page the blocks came from, which is the caller's to know.
489pub fn get_learning_objectives(blocks: &[GutenbergBlock]) -> Option<String> {
490    let mut objectives = Vec::new();
491    collect_learning_objectives(blocks, &mut objectives);
492    (!objectives.is_empty()).then(|| objectives.join("\n"))
493}
494
495fn collect_learning_objectives(blocks: &[GutenbergBlock], objectives: &mut Vec<String>) {
496    for block in blocks {
497        if block.name == "moocfi/learning-objectives"
498            || block.name == "moocfi/course-objective-section"
499        {
500            blocks_text_content(std::slice::from_ref(block), objectives);
501        } else {
502            collect_learning_objectives(&block.inner_blocks, objectives);
503        }
504    }
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    /// A learning-objectives block whose items are the list items authors actually write.
512    fn objectives_block(items: &[&str]) -> GutenbergBlock {
513        GutenbergBlock::block_with_name_attributes_and_inner_blocks(
514            "moocfi/learning-objectives",
515            attributes! {"title": "Learning objectives"},
516            vec![GutenbergBlock::block_with_name_attributes_and_inner_blocks(
517                "core/list",
518                attributes! {},
519                items
520                    .iter()
521                    .map(|item| {
522                        GutenbergBlock::block_with_name_and_attributes(
523                            "core/list-item",
524                            attributes! {"content": *item},
525                        )
526                    })
527                    .collect(),
528            )],
529        )
530    }
531
532    /// Objectives are authored as HTML and shown to a reader as text, and the block sits several
533    /// levels down from the page's top-level blocks.
534    #[test]
535    fn learning_objectives_come_back_as_one_plain_text_line_each() {
536        let page = vec![
537            GutenbergBlock::paragraph("Intro"),
538            GutenbergBlock::block_with_name_attributes_and_inner_blocks(
539                "core/columns",
540                attributes! {},
541                vec![objectives_block(&[
542                    "Ymmärrät mitä <strong>muuttuja</strong> tarkoittaa",
543                    "Osaat <em>tulostaa</em> tekstiä",
544                ])],
545            ),
546        ];
547
548        assert_eq!(
549            get_learning_objectives(&page).as_deref(),
550            Some("Ymmärrät mitä muuttuja tarkoittaa\nOsaat tulostaa tekstiä")
551        );
552    }
553
554    /// A block that declares no objectives counts as no objectives, the same as no block at all:
555    /// the caller omits the field either way rather than printing an empty one.
556    #[test]
557    fn a_page_without_objectives_has_none_whether_or_not_the_block_is_there() {
558        assert_eq!(
559            get_learning_objectives(&[GutenbergBlock::paragraph("Just prose")]),
560            None
561        );
562        assert_eq!(get_learning_objectives(&[objectives_block(&[])]), None);
563    }
564
565    #[test]
566    fn replace_duplicate_client_ids_makes_all_ids_unique_flat_and_nested() {
567        let dup_id = Uuid::new_v4();
568        let nested_dup_id = Uuid::new_v4();
569
570        let block_a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
571        let block_b_child_1 =
572            GutenbergBlock::empty_block_from_name("b1".into()).with_id(nested_dup_id);
573        let block_b_child_2 =
574            GutenbergBlock::empty_block_from_name("b2".into()).with_id(nested_dup_id);
575        let block_b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
576            "b",
577            attributes! {},
578            vec![block_b_child_1, block_b_child_2],
579        )
580        .with_id(dup_id);
581
582        let input = vec![block_a, block_b];
583        let output = replace_duplicate_client_ids(input);
584
585        let ids = all_client_ids(&output);
586        let unique: HashSet<Uuid> = ids.iter().cloned().collect();
587        assert_eq!(
588            unique.len(),
589            ids.len(),
590            "all ids should be unique after replacement"
591        );
592    }
593
594    #[test]
595    fn validate_unique_client_ids_ok_on_unique() {
596        let a = GutenbergBlock::empty_block_from_name("a".into());
597        let b = GutenbergBlock::empty_block_from_name("b".into());
598        let c = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
599            "c",
600            attributes! {},
601            vec![GutenbergBlock::empty_block_from_name("c1".into())],
602        );
603        let input = vec![a, b, c];
604        let result = validate_unique_client_ids(input);
605        assert!(result.is_ok());
606    }
607
608    #[test]
609    fn validate_unique_client_ids_err_on_duplicate_nested() {
610        let dup_id = Uuid::new_v4();
611        let a = GutenbergBlock::empty_block_from_name("a".into()).with_id(dup_id);
612        let b_child = GutenbergBlock::empty_block_from_name("b1".into()).with_id(dup_id);
613        let b = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
614            "b",
615            attributes! {},
616            vec![b_child],
617        );
618        let input = vec![a, b];
619        let result = validate_unique_client_ids(input);
620        assert!(result.is_err());
621    }
622
623    #[test]
624    fn filter_lock_chapter_blocks_removes_inner_blocks_when_not_locked() {
625        let inner_block = GutenbergBlock::block_with_name_and_attributes(
626            "core/paragraph",
627            attributes! {
628                "content": "This should be hidden"
629            },
630        );
631        let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
632            "moocfi/lock-chapter",
633            attributes! {},
634            vec![inner_block.clone()],
635        );
636        let regular_block = GutenbergBlock::block_with_name_and_attributes(
637            "core/heading",
638            attributes! {
639                "content": "Regular heading"
640            },
641        );
642
643        let input = vec![lock_block, regular_block];
644        let result = filter_lock_chapter_blocks(input, false);
645
646        // Lock-chapter block should have no inner blocks
647        assert_eq!(result.len(), 2);
648        assert_eq!(result[0].name, "moocfi/lock-chapter");
649        assert_eq!(result[0].inner_blocks.len(), 0);
650        // Regular block should be unaffected
651        assert_eq!(result[1].name, "core/heading");
652    }
653
654    #[test]
655    fn filter_lock_chapter_blocks_preserves_inner_blocks_when_locked() {
656        let inner_block = GutenbergBlock::block_with_name_and_attributes(
657            "core/paragraph",
658            attributes! {
659                "content": "This should be visible"
660            },
661        );
662        let lock_block = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
663            "moocfi/lock-chapter",
664            attributes! {},
665            vec![inner_block.clone()],
666        );
667
668        let input = vec![lock_block];
669        let result = filter_lock_chapter_blocks(input, true);
670
671        // Lock-chapter block should preserve inner blocks
672        assert_eq!(result.len(), 1);
673        assert_eq!(result[0].name, "moocfi/lock-chapter");
674        assert_eq!(result[0].inner_blocks.len(), 1);
675        assert_eq!(result[0].inner_blocks[0].name, "core/paragraph");
676    }
677
678    #[test]
679    fn filter_lock_chapter_blocks_handles_nested_blocks() {
680        let nested_inner = GutenbergBlock::block_with_name_and_attributes(
681            "core/paragraph",
682            attributes! {
683                "content": "Nested content"
684            },
685        );
686        let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
687            "moocfi/lock-chapter",
688            attributes! {},
689            vec![nested_inner],
690        );
691        let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
692            "moocfi/lock-chapter",
693            attributes! {},
694            vec![nested_lock],
695        );
696
697        let input = vec![outer_lock];
698        let result = filter_lock_chapter_blocks(input, false);
699
700        // All lock-chapter blocks should have inner blocks removed
701        assert_eq!(result.len(), 1);
702        assert_eq!(result[0].name, "moocfi/lock-chapter");
703        assert_eq!(result[0].inner_blocks.len(), 0);
704    }
705
706    #[test]
707    fn filter_lock_chapter_blocks_handles_nested_blocks_when_locked() {
708        let nested_inner = GutenbergBlock::block_with_name_and_attributes(
709            "core/paragraph",
710            attributes! {
711                "content": "Nested content"
712            },
713        );
714        let nested_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
715            "moocfi/lock-chapter",
716            attributes! {},
717            vec![nested_inner.clone()],
718        );
719        let outer_lock = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
720            "moocfi/lock-chapter",
721            attributes! {},
722            vec![nested_lock],
723        );
724
725        let input = vec![outer_lock];
726        let result = filter_lock_chapter_blocks(input, true);
727
728        // All lock-chapter blocks should preserve inner blocks
729        assert_eq!(result.len(), 1);
730        assert_eq!(result[0].name, "moocfi/lock-chapter");
731        assert_eq!(result[0].inner_blocks.len(), 1);
732        assert_eq!(result[0].inner_blocks[0].name, "moocfi/lock-chapter");
733        assert_eq!(result[0].inner_blocks[0].inner_blocks.len(), 1);
734        assert_eq!(
735            result[0].inner_blocks[0].inner_blocks[0].name,
736            "core/paragraph"
737        );
738    }
739
740    #[test]
741    fn filter_lock_chapter_blocks_does_not_affect_non_lock_blocks() {
742        let paragraph = GutenbergBlock::block_with_name_and_attributes(
743            "core/paragraph",
744            attributes! {
745                "content": "Regular paragraph"
746            },
747        );
748        let heading = GutenbergBlock::block_with_name_and_attributes(
749            "core/heading",
750            attributes! {
751                "content": "Regular heading"
752            },
753        );
754        let list_item = GutenbergBlock::block_with_name_and_attributes(
755            "core/list-item",
756            attributes! {
757                "content": "List item"
758            },
759        );
760        let list = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
761            "core/list",
762            attributes! {},
763            vec![list_item],
764        );
765
766        let input = vec![paragraph, heading, list];
767        let result = filter_lock_chapter_blocks(input, false);
768
769        // All blocks should be preserved
770        assert_eq!(result.len(), 3);
771        assert_eq!(result[0].name, "core/paragraph");
772        assert_eq!(result[1].name, "core/heading");
773        assert_eq!(result[2].name, "core/list");
774        assert_eq!(result[2].inner_blocks.len(), 1);
775    }
776
777    fn heading(anchor: Option<&str>) -> GutenbergBlock {
778        let mut attributes = attributes! { "content": "Summary", "level": 2 };
779        if let Some(anchor) = anchor {
780            attributes.insert("anchor".into(), Value::String(anchor.to_string()));
781        }
782        GutenbergBlock::block_with_name_and_attributes("core/heading", attributes)
783    }
784
785    fn anchors(blocks: &[GutenbergBlock]) -> Vec<Option<String>> {
786        let mut anchors = Vec::new();
787        for block in blocks {
788            anchors.push(
789                block
790                    .attributes
791                    .get("anchor")
792                    .and_then(Value::as_str)
793                    .map(str::to_string),
794            );
795            anchors.extend(self::anchors(&block.inner_blocks));
796        }
797        anchors
798    }
799
800    #[test]
801    fn suffix_duplicate_heading_anchors_keeps_the_first_and_numbers_the_rest() {
802        let output = suffix_duplicate_heading_anchors(vec![
803            heading(Some("summary")),
804            heading(Some("summary")),
805            heading(Some("summary")),
806        ]);
807        assert_eq!(
808            anchors(&output),
809            vec![
810                Some("summary".to_string()),
811                Some("summary-2".to_string()),
812                Some("summary-3".to_string()),
813            ]
814        );
815    }
816
817    #[test]
818    fn suffix_duplicate_heading_anchors_skips_suffixes_already_in_use() {
819        let output = suffix_duplicate_heading_anchors(vec![
820            heading(Some("summary")),
821            heading(Some("summary")),
822            heading(Some("summary-2")),
823        ]);
824        assert_eq!(
825            anchors(&output),
826            vec![
827                Some("summary".to_string()),
828                Some("summary-3".to_string()),
829                Some("summary-2".to_string()),
830            ]
831        );
832    }
833
834    #[test]
835    fn suffix_duplicate_heading_anchors_walks_inner_blocks() {
836        let columns = GutenbergBlock::block_with_name_attributes_and_inner_blocks(
837            "core/columns",
838            attributes! {},
839            vec![heading(Some("summary"))],
840        );
841        let output = suffix_duplicate_heading_anchors(vec![heading(Some("summary")), columns]);
842        assert_eq!(
843            anchors(&output),
844            vec![
845                Some("summary".to_string()),
846                None,
847                Some("summary-2".to_string()),
848            ]
849        );
850    }
851
852    #[test]
853    fn suffix_duplicate_heading_anchors_compares_trimmed_anchors() {
854        let output = suffix_duplicate_heading_anchors(vec![
855            heading(Some("summary")),
856            heading(Some(" summary ")),
857        ]);
858        assert_eq!(
859            anchors(&output),
860            vec![Some("summary".to_string()), Some("summary-2".to_string())]
861        );
862    }
863
864    #[test]
865    fn suffix_duplicate_heading_anchors_leaves_unique_blank_and_non_heading_anchors_alone() {
866        let paragraph = GutenbergBlock::block_with_name_and_attributes(
867            "core/paragraph",
868            attributes! { "anchor": "summary" },
869        );
870        let input = vec![
871            heading(Some("summary")),
872            heading(Some("intro")),
873            heading(Some("  ")),
874            heading(Some("  ")),
875            heading(None),
876            paragraph,
877        ];
878        let output = suffix_duplicate_heading_anchors(input.clone());
879        assert_eq!(output, input);
880    }
881}