Skip to content

Commit 1db450d

Browse files
Jonathan D.A. Jewellclaude
andcommitted
fix(seams): Align DocumentEvent with Protocol.res (SEAM-1A)
- Add id and source fields to DocumentEvent enum - Add generate_id() function for unique event IDs - Add EVENT_SOURCE constant for component identification - Create SEAM-CHECK-MUSTS.adoc documenting all seam verifications - Fix gap between Rust and ReScript document event types 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c18a26f commit 1db450d

13 files changed

Lines changed: 2706 additions & 93 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/formatrix-core/src/ast.rs

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)