1use serde::{Deserialize, Serialize};
4
5use crate::content::block::MathFormat;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct Text {
28 pub value: String,
30
31 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub marks: Vec<Mark>,
34}
35
36impl Text {
37 #[must_use]
39 pub fn plain(value: impl Into<String>) -> Self {
40 Self {
41 value: value.into(),
42 marks: Vec::new(),
43 }
44 }
45
46 #[must_use]
48 pub fn with_marks(value: impl Into<String>, marks: Vec<Mark>) -> Self {
49 Self {
50 value: value.into(),
51 marks,
52 }
53 }
54
55 #[must_use]
57 pub fn bold(value: impl Into<String>) -> Self {
58 Self::with_marks(value, vec![Mark::Bold])
59 }
60
61 #[must_use]
63 pub fn italic(value: impl Into<String>) -> Self {
64 Self::with_marks(value, vec![Mark::Italic])
65 }
66
67 #[must_use]
69 pub fn code(value: impl Into<String>) -> Self {
70 Self::with_marks(value, vec![Mark::Code])
71 }
72
73 #[must_use]
75 pub fn link(value: impl Into<String>, href: impl Into<String>) -> Self {
76 Self::with_marks(
77 value,
78 vec![Mark::Link {
79 href: href.into(),
80 title: None,
81 }],
82 )
83 }
84
85 #[must_use]
87 pub fn footnote(value: impl Into<String>, number: u32) -> Self {
88 Self::with_marks(value, vec![Mark::Footnote { number, id: None }])
89 }
90
91 #[must_use]
93 pub fn has_marks(&self) -> bool {
94 !self.marks.is_empty()
95 }
96
97 #[must_use]
99 pub fn has_mark(&self, mark_type: MarkType) -> bool {
100 self.marks.iter().any(|m| m.mark_type() == mark_type)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "camelCase")]
110pub enum Mark {
111 Bold,
113
114 Italic,
116
117 Underline,
119
120 Strikethrough,
122
123 Code,
125
126 Superscript,
128
129 Subscript,
131
132 Link {
134 href: String,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 title: Option<String>,
140 },
141
142 Anchor {
144 id: String,
146 },
147
148 Footnote {
152 number: u32,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 id: Option<String>,
158 },
159
160 Math {
162 format: MathFormat,
164
165 value: String,
167 },
168
169 Extension(ExtensionMark),
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "camelCase")]
185pub struct ExtensionMark {
186 pub namespace: String,
188
189 pub mark_type: String,
191
192 #[serde(default, skip_serializing_if = "serde_json::Value::is_null")]
194 pub attributes: serde_json::Value,
195}
196
197impl ExtensionMark {
198 #[must_use]
200 pub fn new(namespace: impl Into<String>, mark_type: impl Into<String>) -> Self {
201 Self {
202 namespace: namespace.into(),
203 mark_type: mark_type.into(),
204 attributes: serde_json::Value::Null,
205 }
206 }
207
208 #[must_use]
212 pub fn parse_type(type_str: &str) -> Option<(&str, &str)> {
213 type_str.split_once(':')
214 }
215
216 #[must_use]
218 pub fn full_type(&self) -> String {
219 format!("{}:{}", self.namespace, self.mark_type)
220 }
221
222 #[must_use]
224 pub fn is_namespace(&self, namespace: &str) -> bool {
225 self.namespace == namespace
226 }
227
228 #[must_use]
230 pub fn is_type(&self, namespace: &str, mark_type: &str) -> bool {
231 self.namespace == namespace && self.mark_type == mark_type
232 }
233
234 #[must_use]
236 pub fn with_attributes(mut self, attributes: serde_json::Value) -> Self {
237 self.attributes = attributes;
238 self
239 }
240
241 #[must_use]
243 pub fn get_attribute(&self, key: &str) -> Option<&serde_json::Value> {
244 self.attributes.get(key)
245 }
246
247 #[must_use]
249 pub fn get_string_attribute(&self, key: &str) -> Option<&str> {
250 self.attributes.get(key).and_then(serde_json::Value::as_str)
251 }
252
253 #[must_use]
257 pub fn citation(reference: impl Into<String>) -> Self {
258 Self::new("semantic", "citation").with_attributes(serde_json::json!({
259 "ref": reference.into()
260 }))
261 }
262
263 #[must_use]
265 pub fn citation_with_page(reference: impl Into<String>, page: impl Into<String>) -> Self {
266 Self::new("semantic", "citation").with_attributes(serde_json::json!({
267 "ref": reference.into(),
268 "locator": page.into(),
269 "locatorType": "page"
270 }))
271 }
272
273 #[must_use]
275 pub fn entity(uri: impl Into<String>, entity_type: impl Into<String>) -> Self {
276 Self::new("semantic", "entity").with_attributes(serde_json::json!({
277 "uri": uri.into(),
278 "entityType": entity_type.into()
279 }))
280 }
281
282 #[must_use]
284 pub fn glossary(term_id: impl Into<String>) -> Self {
285 Self::new("semantic", "glossary").with_attributes(serde_json::json!({
286 "termId": term_id.into()
287 }))
288 }
289
290 #[must_use]
292 pub fn index(term: impl Into<String>) -> Self {
293 Self::new("presentation", "index").with_attributes(serde_json::json!({
294 "term": term.into()
295 }))
296 }
297
298 #[must_use]
300 pub fn index_with_subterm(term: impl Into<String>, subterm: impl Into<String>) -> Self {
301 Self::new("presentation", "index").with_attributes(serde_json::json!({
302 "term": term.into(),
303 "subterm": subterm.into()
304 }))
305 }
306
307 #[must_use]
313 pub fn equation_ref(target: impl Into<String>) -> Self {
314 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
315 "target": target.into()
316 }))
317 }
318
319 #[must_use]
323 pub fn equation_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
324 Self::new("academic", "equation-ref").with_attributes(serde_json::json!({
325 "target": target.into(),
326 "format": format.into()
327 }))
328 }
329
330 #[must_use]
334 pub fn algorithm_ref(target: impl Into<String>) -> Self {
335 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
336 "target": target.into()
337 }))
338 }
339
340 #[must_use]
344 pub fn algorithm_ref_line(target: impl Into<String>, line: impl Into<String>) -> Self {
345 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
346 "target": target.into(),
347 "line": line.into()
348 }))
349 }
350
351 #[must_use]
355 pub fn algorithm_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
356 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
357 "target": target.into(),
358 "format": format.into()
359 }))
360 }
361
362 #[must_use]
364 pub fn algorithm_ref_line_formatted(
365 target: impl Into<String>,
366 line: impl Into<String>,
367 format: impl Into<String>,
368 ) -> Self {
369 Self::new("academic", "algorithm-ref").with_attributes(serde_json::json!({
370 "target": target.into(),
371 "line": line.into(),
372 "format": format.into()
373 }))
374 }
375
376 #[must_use]
380 pub fn theorem_ref(target: impl Into<String>) -> Self {
381 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
382 "target": target.into()
383 }))
384 }
385
386 #[must_use]
390 pub fn theorem_ref_formatted(target: impl Into<String>, format: impl Into<String>) -> Self {
391 Self::new("academic", "theorem-ref").with_attributes(serde_json::json!({
392 "target": target.into(),
393 "format": format.into()
394 }))
395 }
396
397 #[must_use]
404 pub fn highlight(color: impl Into<String>) -> Self {
405 Self::new("collaboration", "highlight").with_attributes(serde_json::json!({
406 "color": color.into()
407 }))
408 }
409
410 #[must_use]
412 pub fn highlight_yellow() -> Self {
413 Self::highlight("yellow")
414 }
415
416 #[must_use]
420 pub fn highlight_colored(color: impl std::fmt::Display) -> Self {
421 Self::highlight(color.to_string())
422 }
423}
424
425impl Mark {
426 #[must_use]
428 pub fn mark_type(&self) -> MarkType {
429 match self {
430 Self::Bold => MarkType::Bold,
431 Self::Italic => MarkType::Italic,
432 Self::Underline => MarkType::Underline,
433 Self::Strikethrough => MarkType::Strikethrough,
434 Self::Code => MarkType::Code,
435 Self::Superscript => MarkType::Superscript,
436 Self::Subscript => MarkType::Subscript,
437 Self::Link { .. } => MarkType::Link,
438 Self::Anchor { .. } => MarkType::Anchor,
439 Self::Footnote { .. } => MarkType::Footnote,
440 Self::Math { .. } => MarkType::Math,
441 Self::Extension(_) => MarkType::Extension,
442 }
443 }
444
445 #[must_use]
447 pub fn is_extension(&self) -> bool {
448 matches!(self, Self::Extension(_))
449 }
450
451 #[must_use]
453 pub fn as_extension(&self) -> Option<&ExtensionMark> {
454 match self {
455 Self::Extension(ext) => Some(ext),
456 _ => None,
457 }
458 }
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
463pub enum MarkType {
464 Bold,
466 Italic,
468 Underline,
470 Strikethrough,
472 Code,
474 Superscript,
476 Subscript,
478 Link,
480 Anchor,
482 Footnote,
484 Math,
486 Extension,
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn test_text_plain() {
496 let text = Text::plain("Hello");
497 assert_eq!(text.value, "Hello");
498 assert!(text.marks.is_empty());
499 assert!(!text.has_marks());
500 }
501
502 #[test]
503 fn test_text_bold() {
504 let text = Text::bold("Important");
505 assert_eq!(text.marks, vec![Mark::Bold]);
506 assert!(text.has_marks());
507 assert!(text.has_mark(MarkType::Bold));
508 assert!(!text.has_mark(MarkType::Italic));
509 }
510
511 #[test]
512 fn test_text_link() {
513 let text = Text::link("Click", "https://example.com");
514 assert!(text.has_mark(MarkType::Link));
515 if let Mark::Link { href, title } = &text.marks[0] {
516 assert_eq!(href, "https://example.com");
517 assert!(title.is_none());
518 } else {
519 panic!("Expected Link mark");
520 }
521 }
522
523 #[test]
524 fn test_text_serialization() {
525 let text = Text::bold("Test");
526 let json = serde_json::to_string(&text).unwrap();
527 assert!(json.contains("\"value\":\"Test\""));
528 assert!(json.contains("\"type\":\"bold\""));
529 }
530
531 #[test]
532 fn test_text_deserialization() {
533 let json = r#"{"value":"Test","marks":[{"type":"bold"},{"type":"italic"}]}"#;
534 let text: Text = serde_json::from_str(json).unwrap();
535 assert_eq!(text.value, "Test");
536 assert_eq!(text.marks.len(), 2);
537 }
538
539 #[test]
540 fn test_link_with_title() {
541 let json = r#"{"type":"link","href":"https://example.com","title":"Example"}"#;
542 let mark: Mark = serde_json::from_str(json).unwrap();
543 if let Mark::Link { href, title } = mark {
544 assert_eq!(href, "https://example.com");
545 assert_eq!(title, Some("Example".to_string()));
546 } else {
547 panic!("Expected Link mark");
548 }
549 }
550
551 #[test]
552 fn test_text_footnote() {
553 let text = Text::footnote("important claim", 1);
554 assert!(text.has_mark(MarkType::Footnote));
555 if let Mark::Footnote { number, id } = &text.marks[0] {
556 assert_eq!(*number, 1);
557 assert!(id.is_none());
558 } else {
559 panic!("Expected Footnote mark");
560 }
561 }
562
563 #[test]
564 fn test_footnote_mark_serialization() {
565 let mark = Mark::Footnote {
566 number: 1,
567 id: Some("fn1".to_string()),
568 };
569 let json = serde_json::to_string(&mark).unwrap();
570 assert!(json.contains("\"type\":\"footnote\""));
571 assert!(json.contains("\"number\":1"));
572 assert!(json.contains("\"id\":\"fn1\""));
573 }
574
575 #[test]
576 fn test_footnote_mark_deserialization() {
577 let json = r#"{"type":"footnote","number":2,"id":"fn-2"}"#;
578 let mark: Mark = serde_json::from_str(json).unwrap();
579 if let Mark::Footnote { number, id } = mark {
580 assert_eq!(number, 2);
581 assert_eq!(id, Some("fn-2".to_string()));
582 } else {
583 panic!("Expected Footnote mark");
584 }
585 }
586
587 #[test]
588 fn test_footnote_mark_without_id() {
589 let json = r#"{"type":"footnote","number":3}"#;
590 let mark: Mark = serde_json::from_str(json).unwrap();
591 if let Mark::Footnote { number, id } = mark {
592 assert_eq!(number, 3);
593 assert!(id.is_none());
594 } else {
595 panic!("Expected Footnote mark");
596 }
597 }
598
599 #[test]
600 fn test_math_mark() {
601 use crate::content::block::MathFormat;
602
603 let mark = Mark::Math {
604 format: MathFormat::Latex,
605 value: "E = mc^2".to_string(),
606 };
607 assert_eq!(mark.mark_type(), MarkType::Math);
608 }
609
610 #[test]
611 fn test_math_mark_serialization() {
612 use crate::content::block::MathFormat;
613
614 let mark = Mark::Math {
615 format: MathFormat::Latex,
616 value: "\\frac{1}{2}".to_string(),
617 };
618 let json = serde_json::to_string(&mark).unwrap();
619 assert!(json.contains("\"type\":\"math\""));
620 assert!(json.contains("\"format\":\"latex\""));
621 assert!(json.contains("\"value\":\"\\\\frac{1}{2}\""));
622 }
623
624 #[test]
625 fn test_math_mark_deserialization() {
626 use crate::content::block::MathFormat;
627
628 let json = r#"{"type":"math","format":"mathml","value":"<math>...</math>"}"#;
629 let mark: Mark = serde_json::from_str(json).unwrap();
630 if let Mark::Math { format, value } = mark {
631 assert_eq!(format, MathFormat::Mathml);
632 assert_eq!(value, "<math>...</math>");
633 } else {
634 panic!("Expected Math mark");
635 }
636 }
637
638 #[test]
639 fn test_text_with_math_mark() {
640 use crate::content::block::MathFormat;
641
642 let text = Text::with_marks(
643 "x²",
644 vec![Mark::Math {
645 format: MathFormat::Latex,
646 value: "x^2".to_string(),
647 }],
648 );
649 assert!(text.has_mark(MarkType::Math));
650 }
651
652 #[test]
655 fn test_extension_mark_new() {
656 let ext = ExtensionMark::new("semantic", "citation");
657 assert_eq!(ext.namespace, "semantic");
658 assert_eq!(ext.mark_type, "citation");
659 assert_eq!(ext.full_type(), "semantic:citation");
660 }
661
662 #[test]
663 fn test_extension_mark_parse_type() {
664 assert_eq!(
665 ExtensionMark::parse_type("semantic:citation"),
666 Some(("semantic", "citation"))
667 );
668 assert_eq!(
669 ExtensionMark::parse_type("legal:cite"),
670 Some(("legal", "cite"))
671 );
672 assert_eq!(ExtensionMark::parse_type("bold"), None);
673 }
674
675 #[test]
676 fn test_extension_mark_with_attributes() {
677 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
678 "ref": "smith2023",
679 "page": "42"
680 }));
681
682 assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
683 assert_eq!(ext.get_string_attribute("page"), Some("42"));
684 }
685
686 #[test]
687 fn test_extension_mark_namespace_check() {
688 let ext = ExtensionMark::new("semantic", "citation");
689 assert!(ext.is_namespace("semantic"));
690 assert!(!ext.is_namespace("legal"));
691 assert!(ext.is_type("semantic", "citation"));
692 assert!(!ext.is_type("semantic", "entity"));
693 }
694
695 #[test]
696 fn test_mark_extension_variant() {
697 let ext = ExtensionMark::new("semantic", "citation");
698 let mark = Mark::Extension(ext.clone());
699
700 assert!(mark.is_extension());
701 assert_eq!(mark.mark_type(), MarkType::Extension);
702 assert_eq!(
703 mark.as_extension().unwrap().full_type(),
704 "semantic:citation"
705 );
706 }
707
708 #[test]
709 fn test_extension_mark_serialization() {
710 let ext = ExtensionMark::new("semantic", "citation").with_attributes(serde_json::json!({
711 "ref": "smith2023"
712 }));
713 let mark = Mark::Extension(ext);
714
715 let json = serde_json::to_string(&mark).unwrap();
716 assert!(json.contains("\"type\":\"extension\""));
717 assert!(json.contains("\"namespace\":\"semantic\""));
718 assert!(json.contains("\"markType\":\"citation\""));
719 assert!(json.contains("\"ref\":\"smith2023\""));
720 }
721
722 #[test]
723 fn test_extension_mark_deserialization() {
724 let json = r#"{
725 "type": "extension",
726 "namespace": "legal",
727 "markType": "cite",
728 "attributes": {
729 "citation": "Brown v. Board of Education"
730 }
731 }"#;
732 let mark: Mark = serde_json::from_str(json).unwrap();
733
734 if let Mark::Extension(ext) = mark {
735 assert_eq!(ext.namespace, "legal");
736 assert_eq!(ext.mark_type, "cite");
737 assert_eq!(
738 ext.get_string_attribute("citation"),
739 Some("Brown v. Board of Education")
740 );
741 } else {
742 panic!("Expected Extension mark");
743 }
744 }
745
746 #[test]
747 fn test_text_with_extension_mark() {
748 let mark = Mark::Extension(ExtensionMark::citation("smith2023"));
749 let text = Text::with_marks("important claim", vec![mark]);
750
751 assert!(text.has_mark(MarkType::Extension));
752 if let Mark::Extension(ext) = &text.marks[0] {
753 assert_eq!(ext.namespace, "semantic");
754 assert_eq!(ext.mark_type, "citation");
755 } else {
756 panic!("Expected Extension mark");
757 }
758 }
759
760 #[test]
761 fn test_citation_convenience() {
762 let ext = ExtensionMark::citation("smith2023");
763 assert!(ext.is_type("semantic", "citation"));
764 assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
765 }
766
767 #[test]
768 fn test_citation_with_page_convenience() {
769 let ext = ExtensionMark::citation_with_page("smith2023", "42-45");
770 assert!(ext.is_type("semantic", "citation"));
771 assert_eq!(ext.get_string_attribute("ref"), Some("smith2023"));
772 assert_eq!(ext.get_string_attribute("locator"), Some("42-45"));
773 assert_eq!(ext.get_string_attribute("locatorType"), Some("page"));
774 }
775
776 #[test]
777 fn test_entity_convenience() {
778 let ext = ExtensionMark::entity("https://www.wikidata.org/wiki/Q937", "person");
779 assert!(ext.is_type("semantic", "entity"));
780 assert_eq!(
781 ext.get_string_attribute("uri"),
782 Some("https://www.wikidata.org/wiki/Q937")
783 );
784 assert_eq!(ext.get_string_attribute("entityType"), Some("person"));
785 }
786
787 #[test]
788 fn test_glossary_convenience() {
789 let ext = ExtensionMark::glossary("api-term");
790 assert!(ext.is_type("semantic", "glossary"));
791 assert_eq!(ext.get_string_attribute("termId"), Some("api-term"));
792 }
793
794 #[test]
795 fn test_index_convenience() {
796 let ext = ExtensionMark::index("algorithm");
797 assert!(ext.is_type("presentation", "index"));
798 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
799 }
800
801 #[test]
802 fn test_index_with_subterm_convenience() {
803 let ext = ExtensionMark::index_with_subterm("algorithm", "sorting");
804 assert!(ext.is_type("presentation", "index"));
805 assert_eq!(ext.get_string_attribute("term"), Some("algorithm"));
806 assert_eq!(ext.get_string_attribute("subterm"), Some("sorting"));
807 }
808
809 #[test]
810 fn test_non_extension_mark_as_extension() {
811 let mark = Mark::Bold;
812 assert!(!mark.is_extension());
813 assert!(mark.as_extension().is_none());
814 }
815
816 #[test]
817 fn test_equation_ref_convenience() {
818 let ext = ExtensionMark::equation_ref("#eq-pythagoras");
819 assert!(ext.is_type("academic", "equation-ref"));
820 assert_eq!(ext.get_string_attribute("target"), Some("#eq-pythagoras"));
821 assert!(ext.get_string_attribute("format").is_none());
822 }
823
824 #[test]
825 fn test_equation_ref_formatted_convenience() {
826 let ext = ExtensionMark::equation_ref_formatted("#eq-1", "Equation ({number})");
827 assert!(ext.is_type("academic", "equation-ref"));
828 assert_eq!(ext.get_string_attribute("target"), Some("#eq-1"));
829 assert_eq!(
830 ext.get_string_attribute("format"),
831 Some("Equation ({number})")
832 );
833 }
834
835 #[test]
836 fn test_algorithm_ref_convenience() {
837 let ext = ExtensionMark::algorithm_ref("#alg-quicksort");
838 assert!(ext.is_type("academic", "algorithm-ref"));
839 assert_eq!(ext.get_string_attribute("target"), Some("#alg-quicksort"));
840 assert!(ext.get_string_attribute("line").is_none());
841 }
842
843 #[test]
844 fn test_algorithm_ref_line_convenience() {
845 let ext = ExtensionMark::algorithm_ref_line("#alg-bisection", "loop");
846 assert!(ext.is_type("academic", "algorithm-ref"));
847 assert_eq!(ext.get_string_attribute("target"), Some("#alg-bisection"));
848 assert_eq!(ext.get_string_attribute("line"), Some("loop"));
849 }
850
851 #[test]
852 fn test_algorithm_ref_formatted_convenience() {
853 let ext = ExtensionMark::algorithm_ref_formatted("#alg-1", "Algorithm {number}");
854 assert!(ext.is_type("academic", "algorithm-ref"));
855 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
856 assert_eq!(
857 ext.get_string_attribute("format"),
858 Some("Algorithm {number}")
859 );
860 }
861
862 #[test]
863 fn test_algorithm_ref_line_formatted_convenience() {
864 let ext = ExtensionMark::algorithm_ref_line_formatted("#alg-1", "pivot", "line {line}");
865 assert!(ext.is_type("academic", "algorithm-ref"));
866 assert_eq!(ext.get_string_attribute("target"), Some("#alg-1"));
867 assert_eq!(ext.get_string_attribute("line"), Some("pivot"));
868 assert_eq!(ext.get_string_attribute("format"), Some("line {line}"));
869 }
870
871 #[test]
872 fn test_theorem_ref_convenience() {
873 let ext = ExtensionMark::theorem_ref("#thm-pythagoras");
874 assert!(ext.is_type("academic", "theorem-ref"));
875 assert_eq!(ext.get_string_attribute("target"), Some("#thm-pythagoras"));
876 }
877
878 #[test]
879 fn test_theorem_ref_formatted_convenience() {
880 let ext = ExtensionMark::theorem_ref_formatted("#thm-1", "{variant} {number}");
881 assert!(ext.is_type("academic", "theorem-ref"));
882 assert_eq!(ext.get_string_attribute("target"), Some("#thm-1"));
883 assert_eq!(
884 ext.get_string_attribute("format"),
885 Some("{variant} {number}")
886 );
887 }
888
889 #[test]
890 fn test_highlight_mark_convenience() {
891 let ext = ExtensionMark::highlight("yellow");
892 assert!(ext.is_type("collaboration", "highlight"));
893 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
894 }
895
896 #[test]
897 fn test_highlight_yellow_convenience() {
898 let ext = ExtensionMark::highlight_yellow();
899 assert!(ext.is_type("collaboration", "highlight"));
900 assert_eq!(ext.get_string_attribute("color"), Some("yellow"));
901 }
902
903 #[test]
904 fn test_highlight_colored_convenience() {
905 let ext = ExtensionMark::highlight_colored("green");
907 assert!(ext.is_type("collaboration", "highlight"));
908 assert_eq!(ext.get_string_attribute("color"), Some("green"));
909 }
910}
911
912#[cfg(test)]
913mod proptests {
914 use super::*;
915 use proptest::prelude::*;
916
917 fn arb_text_value() -> impl Strategy<Value = String> {
919 "[a-zA-Z0-9 .,!?]{0,100}".prop_map(|s| s)
920 }
921
922 fn arb_url() -> impl Strategy<Value = String> {
924 "(https?://[a-z]{3,10}\\.[a-z]{2,4}(/[a-z0-9]{0,10})?)".prop_map(|s| s)
925 }
926
927 fn arb_simple_mark() -> impl Strategy<Value = Mark> {
929 prop_oneof![
930 Just(Mark::Bold),
931 Just(Mark::Italic),
932 Just(Mark::Underline),
933 Just(Mark::Strikethrough),
934 Just(Mark::Code),
935 Just(Mark::Superscript),
936 Just(Mark::Subscript),
937 ]
938 }
939
940 fn arb_link_mark() -> impl Strategy<Value = Mark> {
942 (arb_url(), prop::option::of("[a-zA-Z ]{0,20}"))
943 .prop_map(|(href, title)| Mark::Link { href, title })
944 }
945
946 fn arb_footnote_mark() -> impl Strategy<Value = Mark> {
948 (1u32..1000u32, prop::option::of("[a-z]{2,10}"))
949 .prop_map(|(number, id)| Mark::Footnote { number, id })
950 }
951
952 fn arb_mark() -> impl Strategy<Value = Mark> {
954 prop_oneof![arb_simple_mark(), arb_link_mark(), arb_footnote_mark(),]
955 }
956
957 fn arb_text() -> impl Strategy<Value = Text> {
959 (arb_text_value(), prop::collection::vec(arb_mark(), 0..3))
960 .prop_map(|(value, marks)| Text { value, marks })
961 }
962
963 proptest! {
964 #[test]
966 fn plain_text_no_marks(value in arb_text_value()) {
967 let text = Text::plain(&value);
968 prop_assert_eq!(&text.value, &value);
969 prop_assert!(text.marks.is_empty());
970 prop_assert!(!text.has_marks());
971 }
972
973 #[test]
975 fn bold_text_has_bold_mark(value in arb_text_value()) {
976 let text = Text::bold(&value);
977 prop_assert_eq!(&text.value, &value);
978 prop_assert_eq!(text.marks.len(), 1);
979 prop_assert!(text.has_mark(MarkType::Bold));
980 }
981
982 #[test]
984 fn italic_text_has_italic_mark(value in arb_text_value()) {
985 let text = Text::italic(&value);
986 prop_assert_eq!(&text.value, &value);
987 prop_assert_eq!(text.marks.len(), 1);
988 prop_assert!(text.has_mark(MarkType::Italic));
989 }
990
991 #[test]
993 fn code_text_has_code_mark(value in arb_text_value()) {
994 let text = Text::code(&value);
995 prop_assert_eq!(&text.value, &value);
996 prop_assert_eq!(text.marks.len(), 1);
997 prop_assert!(text.has_mark(MarkType::Code));
998 }
999
1000 #[test]
1002 fn link_text_has_link_mark(value in arb_text_value(), href in arb_url()) {
1003 let text = Text::link(&value, &href);
1004 prop_assert_eq!(&text.value, &value);
1005 prop_assert_eq!(text.marks.len(), 1);
1006 prop_assert!(text.has_mark(MarkType::Link));
1007 if let Mark::Link { href: actual_href, .. } = &text.marks[0] {
1008 prop_assert_eq!(actual_href, &href);
1009 }
1010 }
1011
1012 #[test]
1014 fn text_json_roundtrip(text in arb_text()) {
1015 let json = serde_json::to_string(&text).unwrap();
1016 let parsed: Text = serde_json::from_str(&json).unwrap();
1017 prop_assert_eq!(text, parsed);
1018 }
1019
1020 #[test]
1022 fn mark_json_roundtrip(mark in arb_mark()) {
1023 let json = serde_json::to_string(&mark).unwrap();
1024 let parsed: Mark = serde_json::from_str(&json).unwrap();
1025 prop_assert_eq!(mark, parsed);
1026 }
1027
1028 #[test]
1030 fn simple_mark_types(mark in arb_simple_mark()) {
1031 let expected = match mark {
1032 Mark::Bold => MarkType::Bold,
1033 Mark::Italic => MarkType::Italic,
1034 Mark::Underline => MarkType::Underline,
1035 Mark::Strikethrough => MarkType::Strikethrough,
1036 Mark::Code => MarkType::Code,
1037 Mark::Superscript => MarkType::Superscript,
1038 Mark::Subscript => MarkType::Subscript,
1039 Mark::Link { .. }
1040 | Mark::Anchor { .. }
1041 | Mark::Footnote { .. }
1042 | Mark::Math { .. }
1043 | Mark::Extension(_) => {
1044 prop_assert!(false, "arb_simple_mark() generated non-simple mark: {mark:?}");
1046 return Ok(());
1047 }
1048 };
1049 prop_assert_eq!(mark.mark_type(), expected);
1050 }
1051
1052 #[test]
1054 fn link_mark_type(mark in arb_link_mark()) {
1055 prop_assert_eq!(mark.mark_type(), MarkType::Link);
1056 }
1057
1058 #[test]
1060 fn footnote_mark_type(mark in arb_footnote_mark()) {
1061 prop_assert_eq!(mark.mark_type(), MarkType::Footnote);
1062 }
1063
1064 #[test]
1066 fn has_marks_consistent(text in arb_text()) {
1067 prop_assert_eq!(text.has_marks(), !text.marks.is_empty());
1068 }
1069 }
1070}