@@ -520,3 +520,247 @@ mod tests {
520520 assert_eq ! ( doc. word_count( ) , 6 ) ;
521521 }
522522}
523+
524+ #[ cfg( test) ]
525+ mod proptests {
526+ use super :: * ;
527+ use proptest:: prelude:: * ;
528+
529+ // Strategy for generating SourceFormat
530+ fn source_format_strategy ( ) -> impl Strategy < Value = SourceFormat > {
531+ prop_oneof ! [
532+ Just ( SourceFormat :: PlainText ) ,
533+ Just ( SourceFormat :: Markdown ) ,
534+ Just ( SourceFormat :: AsciiDoc ) ,
535+ Just ( SourceFormat :: Djot ) ,
536+ Just ( SourceFormat :: OrgMode ) ,
537+ Just ( SourceFormat :: ReStructuredText ) ,
538+ Just ( SourceFormat :: Typst ) ,
539+ ]
540+ }
541+
542+ // Strategy for generating simple text (no special characters for JSON safety)
543+ fn simple_text_strategy ( ) -> impl Strategy < Value = String > {
544+ "[a-zA-Z0-9 ]{0,100}" . prop_map ( |s| s. trim ( ) . to_string ( ) )
545+ }
546+
547+ // Strategy for generating Inline::Text
548+ fn inline_text_strategy ( ) -> impl Strategy < Value = Inline > {
549+ simple_text_strategy ( ) . prop_map ( |content| Inline :: Text { content } )
550+ }
551+
552+ // Strategy for generating simple Inlines (no recursion)
553+ fn simple_inline_strategy ( ) -> impl Strategy < Value = Inline > {
554+ prop_oneof ! [
555+ inline_text_strategy( ) ,
556+ Just ( Inline :: LineBreak ) ,
557+ Just ( Inline :: SoftBreak ) ,
558+ Just ( Inline :: NonBreakingSpace ) ,
559+ simple_text_strategy( ) . prop_map( |content| Inline :: Code {
560+ content,
561+ language: None
562+ } ) ,
563+ ]
564+ }
565+
566+ // Strategy for ListKind
567+ fn list_kind_strategy ( ) -> impl Strategy < Value = ListKind > {
568+ prop_oneof ! [
569+ Just ( ListKind :: Bullet ) ,
570+ Just ( ListKind :: Ordered ) ,
571+ Just ( ListKind :: Task ) ,
572+ ]
573+ }
574+
575+ // Strategy for generating Paragraphs
576+ fn paragraph_strategy ( ) -> impl Strategy < Value = Block > {
577+ prop:: collection:: vec ( simple_inline_strategy ( ) , 0 ..5 ) . prop_map ( |content| {
578+ Block :: Paragraph { content, span : None }
579+ } )
580+ }
581+
582+ // Strategy for generating Headings
583+ fn heading_strategy ( ) -> impl Strategy < Value = Block > {
584+ ( 1u8 ..=6 , prop:: collection:: vec ( simple_inline_strategy ( ) , 1 ..4 ) ) . prop_map (
585+ |( level, content) | Block :: Heading {
586+ level,
587+ content,
588+ id : None ,
589+ span : None ,
590+ } ,
591+ )
592+ }
593+
594+ // Strategy for generating CodeBlocks
595+ fn code_block_strategy ( ) -> impl Strategy < Value = Block > {
596+ (
597+ proptest:: option:: of ( "[a-z]+" ) ,
598+ simple_text_strategy ( ) ,
599+ proptest:: bool:: ANY ,
600+ )
601+ . prop_map ( |( language, content, line_numbers) | Block :: CodeBlock {
602+ language,
603+ content,
604+ line_numbers,
605+ highlight_lines : Vec :: new ( ) ,
606+ span : None ,
607+ } )
608+ }
609+
610+ // Strategy for generating simple blocks (no deep recursion)
611+ fn simple_block_strategy ( ) -> impl Strategy < Value = Block > {
612+ prop_oneof ! [
613+ paragraph_strategy( ) ,
614+ heading_strategy( ) ,
615+ code_block_strategy( ) ,
616+ Just ( Block :: ThematicBreak { span: None } ) ,
617+ ]
618+ }
619+
620+ // Strategy for generating Documents
621+ fn document_strategy ( ) -> impl Strategy < Value = Document > {
622+ (
623+ source_format_strategy ( ) ,
624+ prop:: collection:: vec ( simple_block_strategy ( ) , 0 ..10 ) ,
625+ )
626+ . prop_map ( |( source_format, content) | Document {
627+ source_format,
628+ meta : DocumentMeta :: default ( ) ,
629+ content,
630+ raw_source : None ,
631+ } )
632+ }
633+
634+ // Strategy for MetaValue
635+ fn meta_value_strategy ( ) -> impl Strategy < Value = MetaValue > {
636+ prop_oneof ! [
637+ simple_text_strategy( ) . prop_map( MetaValue :: String ) ,
638+ proptest:: bool :: ANY . prop_map( MetaValue :: Bool ) ,
639+ ( -1000i64 ..1000 ) . prop_map( MetaValue :: Integer ) ,
640+ ( -1000.0f64 ..1000.0 ) . prop_map( MetaValue :: Float ) ,
641+ ]
642+ }
643+
644+ proptest ! {
645+ // Property: All SourceFormats have non-empty extensions
646+ #[ test]
647+ fn prop_source_format_has_extension( format in source_format_strategy( ) ) {
648+ prop_assert!( !format. extension( ) . is_empty( ) ) ;
649+ }
650+
651+ // Property: All SourceFormats have non-empty labels
652+ #[ test]
653+ fn prop_source_format_has_label( format in source_format_strategy( ) ) {
654+ prop_assert!( !format. label( ) . is_empty( ) ) ;
655+ }
656+
657+ // Property: Extension and label are different (except edge cases)
658+ #[ test]
659+ fn prop_source_format_extension_differs_from_label( format in source_format_strategy( ) ) {
660+ // Extensions are lowercase file extensions, labels are display names
661+ prop_assert!( format. extension( ) != format. label( ) || format. extension( ) == format. label( ) . to_lowercase( ) ) ;
662+ }
663+
664+ // Property: Document word_count is non-negative
665+ #[ test]
666+ fn prop_document_word_count_nonnegative( doc in document_strategy( ) ) {
667+ prop_assert!( doc. word_count( ) >= 0 ) ;
668+ }
669+
670+ // Property: Document char_count is non-negative
671+ #[ test]
672+ fn prop_document_char_count_nonnegative( doc in document_strategy( ) ) {
673+ prop_assert!( doc. char_count( ) >= 0 ) ;
674+ }
675+
676+ // Property: Empty document has zero word count
677+ #[ test]
678+ fn prop_empty_document_zero_words( format in source_format_strategy( ) ) {
679+ let doc = Document :: new( format) ;
680+ prop_assert_eq!( doc. word_count( ) , 0 ) ;
681+ }
682+
683+ // Property: Empty document has zero char count
684+ #[ test]
685+ fn prop_empty_document_zero_chars( format in source_format_strategy( ) ) {
686+ let doc = Document :: new( format) ;
687+ prop_assert_eq!( doc. char_count( ) , 0 ) ;
688+ }
689+
690+ // Property: Document serialization roundtrip
691+ #[ test]
692+ fn prop_document_serde_roundtrip( doc in document_strategy( ) ) {
693+ let json = serde_json:: to_string( & doc) . expect( "serialize" ) ;
694+ let deserialized: Document = serde_json:: from_str( & json) . expect( "deserialize" ) ;
695+ prop_assert_eq!( doc. source_format, deserialized. source_format) ;
696+ prop_assert_eq!( doc. content. len( ) , deserialized. content. len( ) ) ;
697+ }
698+
699+ // Property: Inline Text word count equals split_whitespace count
700+ #[ test]
701+ fn prop_inline_text_word_count( content in simple_text_strategy( ) ) {
702+ let inline = Inline :: Text { content: content. clone( ) } ;
703+ prop_assert_eq!( inline. word_count( ) , content. split_whitespace( ) . count( ) ) ;
704+ }
705+
706+ // Property: Inline Text char count equals chars().count()
707+ #[ test]
708+ fn prop_inline_text_char_count( content in simple_text_strategy( ) ) {
709+ let inline = Inline :: Text { content: content. clone( ) } ;
710+ prop_assert_eq!( inline. char_count( ) , content. chars( ) . count( ) ) ;
711+ }
712+
713+ // Property: Heading level is always 1-6
714+ #[ test]
715+ fn prop_heading_level_in_range( level in 1u8 ..=6 , content in prop:: collection:: vec( simple_inline_strategy( ) , 1 ..4 ) ) {
716+ let block = Block :: Heading {
717+ level,
718+ content,
719+ id: None ,
720+ span: None ,
721+ } ;
722+ if let Block :: Heading { level: l, .. } = block {
723+ prop_assert!( l >= 1 && l <= 6 ) ;
724+ }
725+ }
726+
727+ // Property: MetaValue serialization roundtrip
728+ #[ test]
729+ fn prop_meta_value_serde_roundtrip( value in meta_value_strategy( ) ) {
730+ let json = serde_json:: to_string( & value) . expect( "serialize" ) ;
731+ let deserialized: MetaValue = serde_json:: from_str( & json) . expect( "deserialize" ) ;
732+ match ( & value, & deserialized) {
733+ ( MetaValue :: String ( a) , MetaValue :: String ( b) ) => prop_assert_eq!( a, b) ,
734+ ( MetaValue :: Bool ( a) , MetaValue :: Bool ( b) ) => prop_assert_eq!( a, b) ,
735+ ( MetaValue :: Integer ( a) , MetaValue :: Integer ( b) ) => prop_assert_eq!( a, b) ,
736+ ( MetaValue :: Float ( a) , MetaValue :: Float ( b) ) => {
737+ // Float comparison with tolerance
738+ prop_assert!( ( a - b) . abs( ) < 0.0001 ) ;
739+ }
740+ _ => prop_assert!( false , "Type mismatch after roundtrip" ) ,
741+ }
742+ }
743+
744+ // Property: Block paragraph word count is sum of inline word counts
745+ #[ test]
746+ fn prop_paragraph_word_count_sum( inlines in prop:: collection:: vec( inline_text_strategy( ) , 0 ..5 ) ) {
747+ let expected: usize = inlines. iter( ) . map( |i| i. word_count( ) ) . sum( ) ;
748+ let block = Block :: Paragraph { content: inlines, span: None } ;
749+ prop_assert_eq!( block. word_count( ) , expected) ;
750+ }
751+
752+ // Property: ListKind serialization roundtrip
753+ #[ test]
754+ fn prop_list_kind_serde_roundtrip( kind in list_kind_strategy( ) ) {
755+ let json = serde_json:: to_string( & kind) . expect( "serialize" ) ;
756+ let deserialized: ListKind = serde_json:: from_str( & json) . expect( "deserialize" ) ;
757+ prop_assert_eq!( kind, deserialized) ;
758+ }
759+
760+ // Property: SourceFormat::ALL contains all variants
761+ #[ test]
762+ fn prop_source_format_all_contains( format in source_format_strategy( ) ) {
763+ prop_assert!( SourceFormat :: ALL . contains( & format) ) ;
764+ }
765+ }
766+ }
0 commit comments