diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e088aa6..1b0867f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,3 +228,32 @@ jobs: with: path: target/criterion key: benchmark-baseline-main + + feature-matrix: + name: Feature Matrix (${{ matrix.features || 'no features' }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: + - "" + - "zstd" + - "signatures" + - "zstd,signatures" + - "signatures,encryption" + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: features-${{ matrix.features }} + + - name: Build + run: cargo build -p cdx-core --no-default-features --features "${{ matrix.features }}" + + - name: Test + run: cargo test -p cdx-core --no-default-features --features "${{ matrix.features }}" diff --git a/cdx-core/tests/extension_schema.rs b/cdx-core/tests/extension_schema.rs new file mode 100644 index 0000000..44cd24d --- /dev/null +++ b/cdx-core/tests/extension_schema.rs @@ -0,0 +1,337 @@ +//! Extension attribute schema tests. +//! +//! Table-driven tests verifying field names and types for every `ExtensionMark` +//! convenience constructor. These tests prevent field-level spec divergences by +//! asserting the exact attribute schema each constructor produces. + +use cdx_core::content::{ExtensionMark, Mark, Text}; +use serde_json::Value; + +// ===== Helper functions ===== + +/// Serialize an `ExtensionMark` via a `Text` node and return the mark's JSON value. +fn mark_to_json(mark: &ExtensionMark) -> Value { + let text = Text::with_marks("test", vec![Mark::Extension(mark.clone())]); + let json = serde_json::to_value(&text).unwrap(); + // marks is an array; grab the first mark + json["marks"][0].clone() +} + +/// Assert that a JSON object contains a key with a string value. +fn assert_string_field(val: &Value, key: &str, context: &str) { + let field = &val[key]; + assert!( + field.is_string(), + "{context}: expected '{key}' to be a string, got {field}" + ); +} + +/// Assert that a JSON object contains a key with a string array value. +fn assert_string_array_field(val: &Value, key: &str, min_len: usize, context: &str) { + let field = &val[key]; + assert!( + field.is_array(), + "{context}: expected '{key}' to be an array, got {field}" + ); + let arr = field.as_array().unwrap(); + assert!( + arr.len() >= min_len, + "{context}: expected '{key}' array to have >= {min_len} items, got {}", + arr.len() + ); + for (i, item) in arr.iter().enumerate() { + assert!( + item.is_string(), + "{context}: expected '{key}[{i}]' to be a string, got {item}" + ); + } +} + +/// Assert that a JSON object does NOT contain a key. +fn assert_field_absent(val: &Value, key: &str, context: &str) { + assert!( + val.get(key).is_none(), + "{context}: expected '{key}' to be absent, but found {}", + val[key] + ); +} + +// ===== Citation constructors ===== + +#[test] +fn schema_citation_emits_refs_array() { + let mark = ExtensionMark::citation("smith2023"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "semantic:citation", "type field"); + assert_string_array_field(&json, "refs", 1, "citation"); + assert_eq!(json["refs"][0], "smith2023"); + assert_field_absent(&json, "ref", "citation must not emit legacy 'ref'"); +} + +#[test] +fn schema_citation_with_page_emits_refs_array_and_locator() { + let mark = ExtensionMark::citation_with_page("smith2023", "42-45"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "semantic:citation"); + assert_string_array_field(&json, "refs", 1, "citation_with_page"); + assert_string_field(&json, "locator", "citation_with_page"); + assert_string_field(&json, "locatorType", "citation_with_page"); + assert_eq!(json["locator"], "42-45"); + assert_eq!(json["locatorType"], "page"); + assert_field_absent( + &json, + "ref", + "citation_with_page must not emit legacy 'ref'", + ); +} + +#[test] +fn schema_multi_citation_emits_refs_array_with_multiple_items() { + let refs = vec!["smith2023".to_string(), "jones2024".to_string()]; + let mark = ExtensionMark::multi_citation(&refs); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "semantic:citation"); + assert_string_array_field(&json, "refs", 2, "multi_citation"); + assert_eq!(json["refs"][0], "smith2023"); + assert_eq!(json["refs"][1], "jones2024"); + assert_field_absent(&json, "ref", "multi_citation must not emit legacy 'ref'"); +} + +// ===== Entity link ===== + +#[test] +fn schema_entity_emits_uri_and_entity_type() { + let mark = ExtensionMark::entity("https://example.org/entity/1", "person"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "semantic:entity"); + assert_string_field(&json, "uri", "entity"); + assert_string_field(&json, "entityType", "entity"); + assert_eq!(json["uri"], "https://example.org/entity/1"); + assert_eq!(json["entityType"], "person"); +} + +// ===== Glossary ===== + +#[test] +fn schema_glossary_emits_term_id() { + let mark = ExtensionMark::glossary("ai"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "semantic:glossary"); + assert_string_field(&json, "termId", "glossary"); + assert_eq!(json["termId"], "ai"); +} + +// ===== Index ===== + +#[test] +fn schema_index_emits_term() { + let mark = ExtensionMark::index("machine learning"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "presentation:index"); + assert_string_field(&json, "term", "index"); + assert_eq!(json["term"], "machine learning"); +} + +#[test] +fn schema_index_with_subterm_emits_term_and_subterm() { + let mark = ExtensionMark::index_with_subterm("algorithms", "quicksort"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "presentation:index"); + assert_string_field(&json, "term", "index_with_subterm"); + assert_string_field(&json, "subterm", "index_with_subterm"); + assert_eq!(json["term"], "algorithms"); + assert_eq!(json["subterm"], "quicksort"); +} + +// ===== Academic: equation references ===== + +#[test] +fn schema_equation_ref_emits_target() { + let mark = ExtensionMark::equation_ref("#eq-pythagoras"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:equation-ref"); + assert_string_field(&json, "target", "equation_ref"); + assert_eq!(json["target"], "#eq-pythagoras"); +} + +#[test] +fn schema_equation_ref_formatted_emits_target_and_format() { + let mark = ExtensionMark::equation_ref_formatted("#eq-1", "Eq. ({number})"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:equation-ref"); + assert_string_field(&json, "target", "equation_ref_formatted"); + assert_string_field(&json, "format", "equation_ref_formatted"); + assert_eq!(json["target"], "#eq-1"); + assert_eq!(json["format"], "Eq. ({number})"); +} + +// ===== Academic: algorithm references ===== + +#[test] +fn schema_algorithm_ref_emits_target() { + let mark = ExtensionMark::algorithm_ref("#alg-quicksort"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:algorithm-ref"); + assert_string_field(&json, "target", "algorithm_ref"); + assert_eq!(json["target"], "#alg-quicksort"); +} + +#[test] +fn schema_algorithm_ref_line_emits_target_and_line() { + let mark = ExtensionMark::algorithm_ref_line("#alg-quicksort", "5"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:algorithm-ref"); + assert_string_field(&json, "target", "algorithm_ref_line"); + assert_string_field(&json, "line", "algorithm_ref_line"); + assert_eq!(json["target"], "#alg-quicksort"); + assert_eq!(json["line"], "5"); +} + +#[test] +fn schema_algorithm_ref_formatted_emits_target_and_format() { + let mark = ExtensionMark::algorithm_ref_formatted("#alg-1", "Algorithm {number}"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:algorithm-ref"); + assert_string_field(&json, "target", "algorithm_ref_formatted"); + assert_string_field(&json, "format", "algorithm_ref_formatted"); +} + +#[test] +fn schema_algorithm_ref_line_formatted_emits_all_fields() { + let mark = ExtensionMark::algorithm_ref_line_formatted("#alg-1", "5", "Alg. {number}, L{line}"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:algorithm-ref"); + assert_string_field(&json, "target", "algorithm_ref_line_formatted"); + assert_string_field(&json, "line", "algorithm_ref_line_formatted"); + assert_string_field(&json, "format", "algorithm_ref_line_formatted"); + assert_eq!(json["target"], "#alg-1"); + assert_eq!(json["line"], "5"); + assert_eq!(json["format"], "Alg. {number}, L{line}"); +} + +// ===== Academic: theorem references ===== + +#[test] +fn schema_theorem_ref_emits_target() { + let mark = ExtensionMark::theorem_ref("#thm-pythagoras"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:theorem-ref"); + assert_string_field(&json, "target", "theorem_ref"); + assert_eq!(json["target"], "#thm-pythagoras"); +} + +#[test] +fn schema_theorem_ref_formatted_emits_target_and_format() { + let mark = ExtensionMark::theorem_ref_formatted("#thm-1", "Theorem {number}"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "academic:theorem-ref"); + assert_string_field(&json, "target", "theorem_ref_formatted"); + assert_string_field(&json, "format", "theorem_ref_formatted"); + assert_eq!(json["target"], "#thm-1"); + assert_eq!(json["format"], "Theorem {number}"); +} + +// ===== Collaboration: highlight ===== + +#[test] +fn schema_highlight_emits_color() { + let mark = ExtensionMark::highlight("yellow"); + let json = mark_to_json(&mark); + + assert_eq!(json["type"], "collaboration:highlight"); + assert_string_field(&json, "color", "highlight"); + assert_eq!(json["color"], "yellow"); +} + +// ===== Citation backward-compatibility deserialization ===== + +#[test] +fn schema_citation_deserializes_from_new_refs_format() { + let json_str = + r#"{"value":"cited text","marks":[{"type":"semantic:citation","refs":["smith2023"]}]}"#; + let text: Text = serde_json::from_str(json_str).unwrap(); + let mark = &text.marks[0]; + if let cdx_core::content::Mark::Extension(ext) = mark { + assert_eq!( + ext.get_string_array_attribute("refs"), + Some(vec!["smith2023"]) + ); + } else { + panic!("Expected extension mark"); + } +} + +#[test] +fn schema_citation_deserializes_from_legacy_ref_format() { + let json_str = + r#"{"value":"cited text","marks":[{"type":"semantic:citation","ref":"smith2023"}]}"#; + let text: Text = serde_json::from_str(json_str).unwrap(); + let mark = &text.marks[0]; + if let cdx_core::content::Mark::Extension(ext) = mark { + // Legacy "ref" should be accessible via get_citation_refs() + let refs = ext.get_citation_refs().expect("should have citation refs"); + assert_eq!(refs, vec!["smith2023"]); + } else { + panic!("Expected extension mark"); + } +} + +// ===== Citation struct schema tests ===== + +#[test] +fn schema_citation_struct_emits_refs_not_ref() { + use cdx_core::extensions::Citation; + + let cite = Citation::new("smith2023"); + let json = serde_json::to_value(&cite).unwrap(); + + assert!(json.get("refs").is_some(), "Citation must emit 'refs'"); + assert!( + json.get("ref").is_none(), + "Citation must not emit legacy 'ref'" + ); + assert!(json["refs"].is_array()); + assert_eq!(json["refs"][0], "smith2023"); +} + +#[test] +fn schema_citation_struct_accepts_both_ref_and_refs() { + use cdx_core::extensions::Citation; + + // New format: "refs" array + let new_json = r#"{"refs":["smith2023","jones2024"]}"#; + let cite: Citation = serde_json::from_str(new_json).unwrap(); + assert_eq!(cite.refs, vec!["smith2023", "jones2024"]); + + // Legacy format: "ref" string + let old_json = r#"{"ref":"smith2023"}"#; + let cite: Citation = serde_json::from_str(old_json).unwrap(); + assert_eq!(cite.refs, vec!["smith2023"]); +} + +#[test] +fn schema_citation_struct_multi_ref_roundtrip() { + use cdx_core::extensions::Citation; + + let cite = Citation::multi(vec!["a".into(), "b".into(), "c".into()]).with_page("10"); + let json = serde_json::to_string(&cite).unwrap(); + let parsed: Citation = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed.refs, vec!["a", "b", "c"]); + assert_eq!(parsed.locator, Some("10".to_string())); +} diff --git a/cdx-core/tests/integration.rs b/cdx-core/tests/integration.rs index 2a8aba4..36b58b3 100644 --- a/cdx-core/tests/integration.rs +++ b/cdx-core/tests/integration.rs @@ -915,6 +915,68 @@ mod round_trip_tests { Ok(()) } + + /// Test citation marks (single and multi-ref) survive archive roundtrip. + #[test] + fn test_citation_marks_archive_roundtrip() -> Result<()> { + use cdx_core::content::{Block, ExtensionMark, Mark, Text}; + + let temp_dir = tempfile::tempdir().unwrap(); + let file_path = temp_dir.path().join("citation.cdx"); + + // Build a document with single-ref and multi-ref citation marks + let single_cite = Text::with_marks( + "Smith (2023)", + vec![Mark::Extension(ExtensionMark::citation("smith2023"))], + ); + let multi_cite = Text::with_marks( + "Smith & Jones", + vec![Mark::Extension(ExtensionMark::multi_citation(&[ + "smith2023".to_string(), + "jones2024".to_string(), + ]))], + ); + let plain = Text::plain(" discuss this topic."); + + let doc = Document::builder() + .title("Citation Roundtrip Test") + .creator("Test") + .add_block(Block::paragraph(vec![single_cite, multi_cite, plain])) + .build()?; + + doc.save(&file_path)?; + let reopened = Document::open(&file_path)?; + + // Find the paragraph block + let para = &reopened.content().blocks[0]; + if let Block::Paragraph { children, .. } = para { + assert_eq!(children.len(), 3); + + // Verify single-ref citation mark attributes survived + if let Mark::Extension(ext) = &children[0].marks[0] { + let refs = ext + .get_string_array_attribute("refs") + .expect("single cite should have refs"); + assert_eq!(refs, vec!["smith2023"]); + } else { + panic!("Expected extension mark on single citation"); + } + + // Verify multi-ref citation mark attributes survived + if let Mark::Extension(ext) = &children[1].marks[0] { + let refs = ext + .get_string_array_attribute("refs") + .expect("multi cite should have refs"); + assert_eq!(refs, vec!["smith2023", "jones2024"]); + } else { + panic!("Expected extension mark on multi citation"); + } + } else { + panic!("Expected paragraph block"); + } + + Ok(()) + } } /// Certificate revocation tests.