Skip to main content

cdx_core/content/
validation.rs

1//! Content validation.
2
3use std::collections::HashSet;
4use std::fmt;
5
6use super::{Block, Content, Text};
7use crate::extensions::ExtensionBlock;
8
9/// Content structure validation error.
10///
11/// Reports issues with block hierarchy, unique IDs, heading levels,
12/// parent-child constraints, and similar structural rules within
13/// document content.
14///
15/// See also [`crate::validation::SchemaValidationError`] for JSON schema
16/// validation of manifest and metadata files.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ValidationError {
19    /// Path to the invalid element (e.g., `blocks[0].children[1]`).
20    pub path: String,
21
22    /// Description of the validation failure.
23    pub message: String,
24}
25
26impl fmt::Display for ValidationError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        if self.path.is_empty() {
29            write!(f, "{}", self.message)
30        } else {
31            write!(f, "{}: {}", self.path, self.message)
32        }
33    }
34}
35
36impl std::error::Error for ValidationError {}
37
38/// Validate content structure and rules.
39///
40/// This validates:
41/// - Block structure (correct children types)
42/// - Unique block IDs
43/// - Required fields
44/// - Heading levels (1-6)
45/// - List items only in lists
46/// - Table rows only in tables
47/// - Table cells only in rows
48///
49/// # Errors
50///
51/// Returns a vector of validation errors if any are found.
52#[must_use]
53pub fn validate_content(content: &Content) -> Vec<ValidationError> {
54    let mut errors = Vec::new();
55    let mut seen_ids = HashSet::new();
56
57    for (i, block) in content.blocks.iter().enumerate() {
58        let path = format!("blocks[{i}]");
59        validate_block(block, &path, &mut errors, &mut seen_ids, None);
60    }
61
62    errors
63}
64
65/// Parent context for validating child blocks.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67enum ParentContext {
68    List,
69    Table,
70    TableRow,
71    DefinitionList,
72    Figure,
73}
74
75/// Context passed through validation.
76struct ValidationContext<'a> {
77    errors: &'a mut Vec<ValidationError>,
78    seen_ids: &'a mut HashSet<String>,
79}
80
81impl ValidationContext<'_> {
82    fn add_error(&mut self, path: &str, message: impl Into<String>) {
83        self.errors.push(ValidationError {
84            path: path.to_string(),
85            message: message.into(),
86        });
87    }
88}
89
90fn validate_block(
91    block: &Block,
92    path: &str,
93    errors: &mut Vec<ValidationError>,
94    seen_ids: &mut HashSet<String>,
95    parent: Option<ParentContext>,
96) {
97    let mut ctx = ValidationContext { errors, seen_ids };
98
99    // Check ID uniqueness
100    if let Some(id) = block.id() {
101        if !ctx.seen_ids.insert(id.to_string()) {
102            ctx.add_error(path, format!("duplicate block ID: {id}"));
103        }
104    }
105
106    match block {
107        Block::Paragraph { children, .. } => validate_text_children(children, path, ctx.errors),
108        Block::Heading {
109            level, children, ..
110        } => {
111            validate_heading(*level, children, path, ctx.errors);
112        }
113        Block::List { children, .. } => validate_list(children, path, &mut ctx),
114        Block::ListItem { children, .. } => validate_list_item(children, path, parent, &mut ctx),
115        Block::Blockquote { children, .. } => validate_container(children, path, &mut ctx),
116        Block::CodeBlock { children, .. } => validate_code_block(children, path, ctx.errors),
117        Block::HorizontalRule { .. } | Block::Break { .. } | Block::Signature(_) => {}
118        Block::Image(img) => validate_image(img, path, ctx.errors),
119        Block::Table { children, .. } => validate_table(children, path, &mut ctx),
120        Block::TableRow { children, .. } => validate_table_row(children, path, parent, &mut ctx),
121        Block::TableCell(cell) => validate_table_cell(cell, path, parent, ctx.errors),
122        Block::Math(math) => validate_math(math, path, ctx.errors),
123        Block::Extension(ext) => validate_extension(ext, path, &mut ctx),
124        // New block types
125        Block::DefinitionList(dl) => validate_definition_list(&dl.children, path, &mut ctx),
126        Block::DefinitionItem { children, .. } => {
127            validate_definition_item(children, path, parent, &mut ctx);
128        }
129        Block::DefinitionTerm { children, .. } => {
130            validate_text_children(children, path, ctx.errors);
131        }
132        Block::DefinitionDescription { children, .. } => {
133            validate_container(children, path, &mut ctx);
134        }
135        Block::Measurement(m) => validate_measurement(m, path, ctx.errors),
136        Block::Svg(svg) => validate_svg(svg, path, ctx.errors),
137        Block::Barcode(bc) => validate_barcode(bc, path, ctx.errors),
138        Block::Figure(fig) => validate_figure(fig, path, &mut ctx),
139        Block::FigCaption(fc) => validate_figcaption(&fc.children, path, parent, ctx.errors),
140        Block::Admonition(adm) => validate_container(&adm.children, path, &mut ctx),
141    }
142}
143
144fn validate_heading(level: u8, children: &[Text], path: &str, errors: &mut Vec<ValidationError>) {
145    if !(1..=6).contains(&level) {
146        errors.push(ValidationError {
147            path: path.to_string(),
148            message: format!("heading level must be 1-6, got {level}"),
149        });
150    }
151    validate_text_children(children, path, errors);
152}
153
154fn validate_list(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
155    for (i, child) in children.iter().enumerate() {
156        let child_path = format!("{path}.children[{i}]");
157        if !matches!(child, Block::ListItem { .. }) {
158            ctx.add_error(
159                &child_path,
160                format!("list children must be listItem, got {}", child.block_type()),
161            );
162        }
163        validate_block(
164            child,
165            &child_path,
166            ctx.errors,
167            ctx.seen_ids,
168            Some(ParentContext::List),
169        );
170    }
171}
172
173fn validate_list_item(
174    children: &[Block],
175    path: &str,
176    parent: Option<ParentContext>,
177    ctx: &mut ValidationContext<'_>,
178) {
179    if parent != Some(ParentContext::List) {
180        ctx.add_error(path, "listItem must be a child of list");
181    }
182    for (i, child) in children.iter().enumerate() {
183        let child_path = format!("{path}.children[{i}]");
184        validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
185    }
186}
187
188fn validate_container(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
189    for (i, child) in children.iter().enumerate() {
190        let child_path = format!("{path}.children[{i}]");
191        validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
192    }
193}
194
195fn validate_code_block(children: &[Text], path: &str, errors: &mut Vec<ValidationError>) {
196    if children.len() != 1 {
197        errors.push(ValidationError {
198            path: path.to_string(),
199            message: format!(
200                "codeBlock should have exactly 1 text node, got {}",
201                children.len()
202            ),
203        });
204    }
205    for child in children {
206        if !child.marks.is_empty() {
207            errors.push(ValidationError {
208                path: path.to_string(),
209                message: "codeBlock text should not have marks".to_string(),
210            });
211        }
212    }
213}
214
215fn validate_image(img: &super::block::ImageBlock, path: &str, errors: &mut Vec<ValidationError>) {
216    if img.src.is_empty() {
217        errors.push(ValidationError {
218            path: path.to_string(),
219            message: "image src is required".to_string(),
220        });
221    }
222    if img.alt.is_empty() {
223        errors.push(ValidationError {
224            path: path.to_string(),
225            message: "image alt is required".to_string(),
226        });
227    }
228}
229
230fn validate_table(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
231    for (i, child) in children.iter().enumerate() {
232        let child_path = format!("{path}.children[{i}]");
233        if !matches!(child, Block::TableRow { .. }) {
234            ctx.add_error(
235                &child_path,
236                format!(
237                    "table children must be tableRow, got {}",
238                    child.block_type()
239                ),
240            );
241        }
242        validate_block(
243            child,
244            &child_path,
245            ctx.errors,
246            ctx.seen_ids,
247            Some(ParentContext::Table),
248        );
249    }
250}
251
252fn validate_table_row(
253    children: &[Block],
254    path: &str,
255    parent: Option<ParentContext>,
256    ctx: &mut ValidationContext<'_>,
257) {
258    if parent != Some(ParentContext::Table) {
259        ctx.add_error(path, "tableRow must be a child of table");
260    }
261    for (i, child) in children.iter().enumerate() {
262        let child_path = format!("{path}.children[{i}]");
263        if !matches!(child, Block::TableCell(_)) {
264            ctx.add_error(
265                &child_path,
266                format!(
267                    "tableRow children must be tableCell, got {}",
268                    child.block_type()
269                ),
270            );
271        }
272        validate_block(
273            child,
274            &child_path,
275            ctx.errors,
276            ctx.seen_ids,
277            Some(ParentContext::TableRow),
278        );
279    }
280}
281
282fn validate_table_cell(
283    cell: &super::block::TableCellBlock,
284    path: &str,
285    parent: Option<ParentContext>,
286    errors: &mut Vec<ValidationError>,
287) {
288    if parent != Some(ParentContext::TableRow) {
289        errors.push(ValidationError {
290            path: path.to_string(),
291            message: "tableCell must be a child of tableRow".to_string(),
292        });
293    }
294    if cell.colspan == 0 {
295        errors.push(ValidationError {
296            path: path.to_string(),
297            message: "tableCell colspan must be at least 1".to_string(),
298        });
299    }
300    if cell.rowspan == 0 {
301        errors.push(ValidationError {
302            path: path.to_string(),
303            message: "tableCell rowspan must be at least 1".to_string(),
304        });
305    }
306    validate_text_children(&cell.children, path, errors);
307}
308
309fn validate_math(math: &super::block::MathBlock, path: &str, errors: &mut Vec<ValidationError>) {
310    if math.value.is_empty() {
311        errors.push(ValidationError {
312            path: path.to_string(),
313            message: "math value is required".to_string(),
314        });
315    }
316}
317
318fn validate_extension(ext: &ExtensionBlock, path: &str, ctx: &mut ValidationContext<'_>) {
319    // Validate extension namespace and type
320    if ext.namespace.is_empty() {
321        ctx.add_error(path, "extension namespace is required");
322    }
323    if ext.block_type.is_empty() {
324        ctx.add_error(path, "extension block type is required");
325    }
326
327    // Validate children recursively
328    for (i, child) in ext.children.iter().enumerate() {
329        let child_path = format!("{path}.children[{i}]");
330        validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
331    }
332
333    // Validate fallback content if present
334    if let Some(fallback) = &ext.fallback {
335        let fallback_path = format!("{path}.fallback");
336        validate_block(fallback, &fallback_path, ctx.errors, ctx.seen_ids, None);
337    }
338}
339
340fn validate_text_children(children: &[Text], path: &str, errors: &mut Vec<ValidationError>) {
341    for (i, text) in children.iter().enumerate() {
342        if text.value.is_empty() {
343            errors.push(ValidationError {
344                path: format!("{path}.children[{i}]"),
345                message: "text value cannot be empty".to_string(),
346            });
347        }
348    }
349}
350
351fn validate_definition_list(children: &[Block], path: &str, ctx: &mut ValidationContext<'_>) {
352    for (i, child) in children.iter().enumerate() {
353        let child_path = format!("{path}.children[{i}]");
354        if !matches!(child, Block::DefinitionItem { .. }) {
355            ctx.add_error(
356                &child_path,
357                format!(
358                    "definitionList children must be definitionItem, got {}",
359                    child.block_type()
360                ),
361            );
362        }
363        validate_block(
364            child,
365            &child_path,
366            ctx.errors,
367            ctx.seen_ids,
368            Some(ParentContext::DefinitionList),
369        );
370    }
371}
372
373fn validate_definition_item(
374    children: &[Block],
375    path: &str,
376    parent: Option<ParentContext>,
377    ctx: &mut ValidationContext<'_>,
378) {
379    if parent != Some(ParentContext::DefinitionList) {
380        ctx.add_error(path, "definitionItem must be a child of definitionList");
381    }
382    for (i, child) in children.iter().enumerate() {
383        let child_path = format!("{path}.children[{i}]");
384        validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
385    }
386}
387
388fn validate_measurement(
389    m: &super::block::MeasurementBlock,
390    path: &str,
391    errors: &mut Vec<ValidationError>,
392) {
393    if m.display.is_empty() {
394        errors.push(ValidationError {
395            path: path.to_string(),
396            message: "measurement display is required".to_string(),
397        });
398    }
399}
400
401fn validate_svg(svg: &super::block::SvgBlock, path: &str, errors: &mut Vec<ValidationError>) {
402    // SVG must have exactly one of src or content
403    match (&svg.src, &svg.content) {
404        (Some(_), Some(_)) => {
405            errors.push(ValidationError {
406                path: path.to_string(),
407                message: "svg must have either src or content, not both".to_string(),
408            });
409        }
410        (None, None) => {
411            errors.push(ValidationError {
412                path: path.to_string(),
413                message: "svg must have either src or content".to_string(),
414            });
415        }
416        _ => {}
417    }
418}
419
420fn validate_barcode(
421    bc: &super::block::BarcodeBlock,
422    path: &str,
423    errors: &mut Vec<ValidationError>,
424) {
425    if bc.data.is_empty() {
426        errors.push(ValidationError {
427            path: path.to_string(),
428            message: "barcode data is required".to_string(),
429        });
430    }
431    // Check for generic/placeholder alt text
432    let alt_lower = bc.alt.to_lowercase();
433    if bc.alt.is_empty() || alt_lower == "barcode" || alt_lower == "qr code" || alt_lower == "image"
434    {
435        errors.push(ValidationError {
436            path: path.to_string(),
437            message: "barcode alt must be meaningful (not just 'barcode' or 'image')".to_string(),
438        });
439    }
440}
441
442fn validate_figure(fig: &super::block::FigureBlock, path: &str, ctx: &mut ValidationContext<'_>) {
443    for (i, child) in fig.children.iter().enumerate() {
444        let child_path = format!("{path}.children[{i}]");
445        validate_block(
446            child,
447            &child_path,
448            ctx.errors,
449            ctx.seen_ids,
450            Some(ParentContext::Figure),
451        );
452    }
453
454    // Validate subfigures if present
455    if let Some(ref subfigures) = fig.subfigures {
456        for (i, subfig) in subfigures.iter().enumerate() {
457            let subfig_path = format!("{path}.subfigures[{i}]");
458
459            // Check subfigure ID uniqueness
460            if let Some(ref id) = subfig.id {
461                if !ctx.seen_ids.insert(id.clone()) {
462                    ctx.add_error(&subfig_path, format!("duplicate block ID: {id}"));
463                }
464            }
465
466            // Validate subfigure children
467            for (j, child) in subfig.children.iter().enumerate() {
468                let child_path = format!("{subfig_path}.children[{j}]");
469                validate_block(child, &child_path, ctx.errors, ctx.seen_ids, None);
470            }
471        }
472    }
473}
474
475fn validate_figcaption(
476    children: &[Text],
477    path: &str,
478    parent: Option<ParentContext>,
479    errors: &mut Vec<ValidationError>,
480) {
481    if parent != Some(ParentContext::Figure) {
482        errors.push(ValidationError {
483            path: path.to_string(),
484            message: "figcaption should be a child of figure".to_string(),
485        });
486    }
487    validate_text_children(children, path, errors);
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493    use crate::content::{BlockAttributes, Mark, Text};
494
495    #[test]
496    fn test_valid_content() {
497        let content = Content::new(vec![
498            Block::heading(1, vec![Text::plain("Title")]),
499            Block::paragraph(vec![Text::plain("Body")]),
500        ]);
501        let errors = validate_content(&content);
502        assert!(errors.is_empty());
503    }
504
505    #[test]
506    fn test_duplicate_ids() {
507        let content = Content::new(vec![
508            Block::Paragraph {
509                id: Some("dup".to_string()),
510                children: vec![Text::plain("First")],
511                attributes: BlockAttributes::default(),
512            },
513            Block::Paragraph {
514                id: Some("dup".to_string()),
515                children: vec![Text::plain("Second")],
516                attributes: BlockAttributes::default(),
517            },
518        ]);
519        let errors = validate_content(&content);
520        assert_eq!(errors.len(), 1);
521        assert!(errors[0].message.contains("duplicate"));
522    }
523
524    #[test]
525    fn test_invalid_heading_level() {
526        let content = Content::new(vec![Block::Heading {
527            id: None,
528            level: 7,
529            children: vec![Text::plain("Too deep")],
530            attributes: BlockAttributes::default(),
531        }]);
532        let errors = validate_content(&content);
533        assert_eq!(errors.len(), 1);
534        assert!(errors[0].message.contains("level"));
535    }
536
537    #[test]
538    fn test_list_item_outside_list() {
539        let content = Content::new(vec![Block::list_item(vec![Block::paragraph(vec![
540            Text::plain("Orphan"),
541        ])])]);
542        let errors = validate_content(&content);
543        assert_eq!(errors.len(), 1);
544        assert!(errors[0].message.contains("child of list"));
545    }
546
547    #[test]
548    fn test_list_with_wrong_children() {
549        let content = Content::new(vec![Block::List {
550            id: None,
551            ordered: false,
552            start: None,
553            children: vec![Block::paragraph(vec![Text::plain("Wrong")])],
554            attributes: BlockAttributes::default(),
555        }]);
556        let errors = validate_content(&content);
557        assert_eq!(errors.len(), 1);
558        assert!(errors[0].message.contains("listItem"));
559    }
560
561    #[test]
562    fn test_code_block_with_marks() {
563        let content = Content::new(vec![Block::CodeBlock {
564            id: None,
565            language: Some("rust".to_string()),
566            highlighting: None,
567            tokens: None,
568            children: vec![Text::with_marks("code", vec![Mark::Bold])],
569            attributes: BlockAttributes::default(),
570        }]);
571        let errors = validate_content(&content);
572        assert_eq!(errors.len(), 1);
573        assert!(errors[0].message.contains("marks"));
574    }
575
576    #[test]
577    fn test_empty_image() {
578        let content = Content::new(vec![Block::Image(super::super::block::ImageBlock {
579            id: None,
580            src: String::new(),
581            alt: String::new(),
582            title: None,
583            width: None,
584            height: None,
585        })]);
586        let errors = validate_content(&content);
587        assert_eq!(errors.len(), 2);
588    }
589
590    #[test]
591    fn test_valid_table() {
592        let content = Content::new(vec![Block::table(vec![Block::table_row(
593            vec![Block::table_cell(vec![Text::plain("Cell")])],
594            false,
595        )])]);
596        let errors = validate_content(&content);
597        assert!(errors.is_empty());
598    }
599
600    #[test]
601    fn test_table_row_outside_table() {
602        let content = Content::new(vec![Block::table_row(
603            vec![Block::table_cell(vec![Text::plain("Orphan")])],
604            false,
605        )]);
606        let errors = validate_content(&content);
607        assert!(errors.iter().any(|e| e.message.contains("child of table")));
608    }
609
610    // Tests for new block type validation
611
612    #[test]
613    fn test_valid_definition_list() {
614        let content = Content::new(vec![Block::definition_list(vec![Block::definition_item(
615            vec![
616                Block::definition_term(vec![Text::plain("Term")]),
617                Block::definition_description(vec![Block::paragraph(vec![Text::plain("Desc")])]),
618            ],
619        )])]);
620        let errors = validate_content(&content);
621        assert!(errors.is_empty());
622    }
623
624    #[test]
625    fn test_definition_item_outside_list() {
626        let content = Content::new(vec![Block::definition_item(vec![Block::definition_term(
627            vec![Text::plain("Orphan term")],
628        )])]);
629        let errors = validate_content(&content);
630        assert!(errors
631            .iter()
632            .any(|e| e.message.contains("child of definitionList")));
633    }
634
635    #[test]
636    fn test_definition_list_with_wrong_children() {
637        let content = Content::new(vec![Block::DefinitionList(
638            super::super::block::DefinitionListBlock::new(vec![Block::paragraph(vec![
639                Text::plain("Wrong"),
640            ])]),
641        )]);
642        let errors = validate_content(&content);
643        assert!(errors.iter().any(|e| e.message.contains("definitionItem")));
644    }
645
646    #[test]
647    fn test_svg_with_both_src_and_content() {
648        let content = Content::new(vec![Block::Svg(super::super::block::SvgBlock {
649            id: None,
650            src: Some("file.svg".to_string()),
651            content: Some("<svg>...</svg>".to_string()),
652            width: None,
653            height: None,
654            alt: None,
655        })]);
656        let errors = validate_content(&content);
657        assert!(errors
658            .iter()
659            .any(|e| e.message.contains("either src or content, not both")));
660    }
661
662    #[test]
663    fn test_svg_with_neither_src_nor_content() {
664        let content = Content::new(vec![Block::Svg(super::super::block::SvgBlock {
665            id: None,
666            src: None,
667            content: None,
668            width: None,
669            height: None,
670            alt: None,
671        })]);
672        let errors = validate_content(&content);
673        assert!(errors
674            .iter()
675            .any(|e| e.message.contains("either src or content")));
676    }
677
678    #[test]
679    fn test_barcode_with_generic_alt() {
680        use super::super::block::{BarcodeBlock, BarcodeFormat};
681
682        let content = Content::new(vec![Block::Barcode(BarcodeBlock::new(
683            BarcodeFormat::Qr,
684            "https://example.com",
685            "barcode", // Generic alt text should fail
686        ))]);
687        let errors = validate_content(&content);
688        assert!(errors.iter().any(|e| e.message.contains("meaningful")));
689    }
690
691    #[test]
692    fn test_barcode_with_good_alt() {
693        use super::super::block::{BarcodeBlock, BarcodeFormat};
694
695        let content = Content::new(vec![Block::Barcode(BarcodeBlock::new(
696            BarcodeFormat::Qr,
697            "https://example.com",
698            "Link to example.com homepage",
699        ))]);
700        let errors = validate_content(&content);
701        assert!(errors.is_empty());
702    }
703
704    #[test]
705    fn test_valid_figure() {
706        let content = Content::new(vec![Block::figure(vec![
707            Block::image("photo.png", "A photo"),
708            Block::figcaption(vec![Text::plain("Figure 1")]),
709        ])]);
710        let errors = validate_content(&content);
711        assert!(errors.is_empty());
712    }
713
714    #[test]
715    fn test_figcaption_outside_figure() {
716        let content = Content::new(vec![Block::figcaption(vec![Text::plain("Orphan caption")])]);
717        let errors = validate_content(&content);
718        assert!(errors.iter().any(|e| e.message.contains("child of figure")));
719    }
720
721    #[test]
722    fn test_subfigure_with_invalid_block() {
723        use super::super::block::{FigureBlock, Subfigure};
724
725        let fig = FigureBlock {
726            id: None,
727            numbering: None,
728            subfigures: Some(vec![Subfigure {
729                id: Some("sub-a".to_string()),
730                label: Some("(a)".to_string()),
731                children: vec![Block::Image(super::super::block::ImageBlock {
732                    id: None,
733                    src: String::new(), // invalid: empty src
734                    alt: String::new(), // invalid: empty alt
735                    title: None,
736                    width: None,
737                    height: None,
738                })],
739            }]),
740            children: vec![Block::image("main.png", "Main image")],
741            attributes: BlockAttributes::default(),
742        };
743
744        let content = Content::new(vec![Block::Figure(fig)]);
745        let errors = validate_content(&content);
746        assert!(
747            !errors.is_empty(),
748            "subfigure with invalid block should produce errors"
749        );
750        // Should have errors for empty src and empty alt
751        assert!(errors.iter().any(|e| e.message.contains("src")));
752        assert!(errors.iter().any(|e| e.message.contains("alt")));
753    }
754
755    #[test]
756    fn test_subfigure_duplicate_id() {
757        use super::super::block::{FigureBlock, Subfigure};
758
759        let fig = FigureBlock {
760            id: Some("fig-1".to_string()),
761            numbering: None,
762            subfigures: Some(vec![Subfigure {
763                id: Some("fig-1".to_string()), // duplicate of parent
764                label: None,
765                children: vec![Block::paragraph(vec![Text::plain("subfig")])],
766            }]),
767            children: vec![Block::paragraph(vec![Text::plain("content")])],
768            attributes: BlockAttributes::default(),
769        };
770
771        let content = Content::new(vec![Block::Figure(fig)]);
772        let errors = validate_content(&content);
773        assert!(errors.iter().any(|e| e.message.contains("duplicate")));
774    }
775
776    #[test]
777    fn test_heading_level_clamped_on_deser() {
778        let json = r#"{"type":"heading","level":0,"children":[{"value":"Zero"}]}"#;
779        let block: Block = serde_json::from_str(json).unwrap();
780        if let Block::Heading { level, .. } = block {
781            assert_eq!(level, 1, "level 0 should be clamped to 1");
782        } else {
783            panic!("Expected Heading");
784        }
785
786        let json = r#"{"type":"heading","level":99,"children":[{"value":"High"}]}"#;
787        let block: Block = serde_json::from_str(json).unwrap();
788        if let Block::Heading { level, .. } = block {
789            assert_eq!(level, 6, "level 99 should be clamped to 6");
790        } else {
791            panic!("Expected Heading");
792        }
793    }
794
795    #[test]
796    fn test_measurement_empty_display() {
797        let content = Content::new(vec![Block::Measurement(
798            super::super::block::MeasurementBlock {
799                id: None,
800                value: 42.0,
801                uncertainty: None,
802                uncertainty_notation: None,
803                exponent: None,
804                display: String::new(), // Empty display should fail
805                unit: None,
806            },
807        )]);
808        let errors = validate_content(&content);
809        assert!(errors.iter().any(|e| e.message.contains("display")));
810    }
811}