Skip to content

Commit 2534e41

Browse files
alanbldclaude
andcommitted
test(ooxml): Sprint 9 - StyleSheet edge cases + relationship convenience tests
StyleSheet edge case tests (8 new): - Circular reference detection (A→B→A) - Self-reference detection (A→A) - Deep inheritance chain (5 levels) - Missing base style handling - Empty chain for unknown styles - is_heading and heading_level utilities - Paragraph/table style iteration Relationships convenience method tests (6 new): - Various protocols (HTTP, FTP, mailto) - Various image paths (relative, spaces) - Query parameters with special chars - Type constants validation - Iterator filtering by type - Empty target handling 266 tests total (up from 253) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 3676623 commit 2534e41

File tree

2 files changed

+305
-0
lines changed

2 files changed

+305
-0
lines changed

crates/utf8dok-ooxml/src/relationships.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,4 +716,125 @@ mod tests {
716716
assert_eq!(reparsed.get("rId1"), Some("path/with spaces/file.xml"));
717717
assert_eq!(reparsed.get("rId2"), Some("unicode/日本語/ファイル.xml"));
718718
}
719+
720+
// ==================== Sprint 9: Additional Edge Cases ====================
721+
722+
#[test]
723+
fn test_add_hyperlink_various_protocols() {
724+
let mut rels = Relationships::new();
725+
726+
// Standard HTTP
727+
let http_id = rels.add_hyperlink("https://example.com/page");
728+
assert!(rels.is_hyperlink(&http_id));
729+
730+
// FTP protocol
731+
let ftp_id = rels.add_hyperlink("ftp://files.example.com/download.zip");
732+
assert!(rels.is_hyperlink(&ftp_id));
733+
734+
// Mailto link
735+
let mail_id = rels.add_hyperlink("mailto:user@example.com");
736+
assert!(rels.is_hyperlink(&mail_id));
737+
738+
// Verify all are retrievable
739+
assert_eq!(rels.get(&http_id), Some("https://example.com/page"));
740+
assert_eq!(rels.get(&ftp_id), Some("ftp://files.example.com/download.zip"));
741+
assert_eq!(rels.get(&mail_id), Some("mailto:user@example.com"));
742+
}
743+
744+
#[test]
745+
fn test_add_image_various_paths() {
746+
let mut rels = Relationships::new();
747+
748+
// Standard media path
749+
let id1 = rels.add_image("media/image1.png");
750+
assert_eq!(rels.get(&id1), Some("media/image1.png"));
751+
752+
// Relative path with ..
753+
let id2 = rels.add_image("../media/image2.jpg");
754+
assert_eq!(rels.get(&id2), Some("../media/image2.jpg"));
755+
756+
// Path with spaces
757+
let id3 = rels.add_image("media/my image.png");
758+
assert_eq!(rels.get(&id3), Some("media/my image.png"));
759+
760+
// All should be images
761+
assert!(rels.is_image(&id1));
762+
assert!(rels.is_image(&id2));
763+
assert!(rels.is_image(&id3));
764+
}
765+
766+
#[test]
767+
fn test_hyperlink_with_query_params() {
768+
let mut rels = Relationships::new();
769+
770+
// URL with query parameters containing special chars
771+
let url = "https://example.com/search?q=test&page=1&filter=a<b";
772+
let id = rels.add_hyperlink(url);
773+
774+
// Should store the URL as-is
775+
assert_eq!(rels.get(&id), Some(url));
776+
777+
// Should serialize and parse correctly
778+
let xml = rels.to_xml();
779+
let reparsed = Relationships::parse(xml.as_bytes()).unwrap();
780+
assert_eq!(reparsed.get(&id), Some(url));
781+
}
782+
783+
#[test]
784+
fn test_relationship_type_constants() {
785+
// Verify all type constants are valid URIs
786+
assert!(Relationships::TYPE_STYLES.starts_with("http://"));
787+
assert!(Relationships::TYPE_NUMBERING.starts_with("http://"));
788+
assert!(Relationships::TYPE_SETTINGS.starts_with("http://"));
789+
assert!(Relationships::TYPE_FONT_TABLE.starts_with("http://"));
790+
assert!(Relationships::TYPE_IMAGE.starts_with("http://"));
791+
assert!(Relationships::TYPE_HYPERLINK.starts_with("http://"));
792+
793+
// Verify constants are unique
794+
let types = [
795+
Relationships::TYPE_STYLES,
796+
Relationships::TYPE_NUMBERING,
797+
Relationships::TYPE_SETTINGS,
798+
Relationships::TYPE_FONT_TABLE,
799+
Relationships::TYPE_IMAGE,
800+
Relationships::TYPE_HYPERLINK,
801+
];
802+
let unique: std::collections::HashSet<_> = types.iter().collect();
803+
assert_eq!(unique.len(), types.len(), "All type constants should be unique");
804+
}
805+
806+
#[test]
807+
fn test_iter_with_type_filter() {
808+
let mut rels = Relationships::new();
809+
810+
rels.add_image("media/img1.png");
811+
rels.add_image("media/img2.jpg");
812+
rels.add_hyperlink("https://example.com");
813+
rels.add("styles.xml".to_string(), Relationships::TYPE_STYLES.to_string());
814+
815+
// Count by type
816+
let image_count = rels.iter()
817+
.filter(|(_, rel)| rel.rel_type == Relationships::TYPE_IMAGE)
818+
.count();
819+
let link_count = rels.iter()
820+
.filter(|(_, rel)| rel.rel_type == Relationships::TYPE_HYPERLINK)
821+
.count();
822+
823+
assert_eq!(image_count, 2);
824+
assert_eq!(link_count, 1);
825+
}
826+
827+
#[test]
828+
fn test_empty_target_handling() {
829+
let mut rels = Relationships::new();
830+
831+
// Add relationship with empty target (edge case)
832+
let id = rels.add(String::new(), "type".to_string());
833+
assert_eq!(rels.get(&id), Some(""));
834+
835+
// Should serialize and parse correctly
836+
let xml = rels.to_xml();
837+
let reparsed = Relationships::parse(xml.as_bytes()).unwrap();
838+
assert_eq!(reparsed.get(&id), Some(""));
839+
}
719840
}

crates/utf8dok-ooxml/src/styles.rs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,4 +751,188 @@ mod tests {
751751
assert!(!styles.get("CompanyHeader").unwrap().builtin);
752752
assert!(!styles.get("LegalDisclaimer").unwrap().builtin);
753753
}
754+
755+
// ==================== Sprint 9: StyleSheet Edge Cases ====================
756+
757+
#[test]
758+
fn test_resolve_chain_circular_reference() {
759+
// Create styles with circular inheritance: A→B→A
760+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
761+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
762+
<w:style w:type="paragraph" w:styleId="StyleA">
763+
<w:name w:val="Style A"/>
764+
<w:basedOn w:val="StyleB"/>
765+
</w:style>
766+
<w:style w:type="paragraph" w:styleId="StyleB">
767+
<w:name w:val="Style B"/>
768+
<w:basedOn w:val="StyleA"/>
769+
</w:style>
770+
</w:styles>"#;
771+
772+
let styles = StyleSheet::parse(xml).unwrap();
773+
774+
// Should not panic or loop forever - breaks at cycle detection
775+
let chain = styles.resolve_chain("StyleA");
776+
777+
// Chain should contain at most both styles (breaks at seen check)
778+
assert!(chain.len() <= 2);
779+
assert_eq!(chain[0].id, "StyleA");
780+
}
781+
782+
#[test]
783+
fn test_resolve_chain_self_reference() {
784+
// Create a style that references itself
785+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
786+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
787+
<w:style w:type="paragraph" w:styleId="SelfRef">
788+
<w:name w:val="Self Reference"/>
789+
<w:basedOn w:val="SelfRef"/>
790+
</w:style>
791+
</w:styles>"#;
792+
793+
let styles = StyleSheet::parse(xml).unwrap();
794+
795+
// Should not loop - breaks immediately on self-reference
796+
let chain = styles.resolve_chain("SelfRef");
797+
798+
assert_eq!(chain.len(), 1);
799+
assert_eq!(chain[0].id, "SelfRef");
800+
}
801+
802+
#[test]
803+
fn test_resolve_chain_deep_inheritance() {
804+
// Create a deep inheritance chain: A→B→C→D→E
805+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
806+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
807+
<w:style w:type="paragraph" w:styleId="StyleA">
808+
<w:name w:val="Style A"/>
809+
<w:basedOn w:val="StyleB"/>
810+
</w:style>
811+
<w:style w:type="paragraph" w:styleId="StyleB">
812+
<w:name w:val="Style B"/>
813+
<w:basedOn w:val="StyleC"/>
814+
</w:style>
815+
<w:style w:type="paragraph" w:styleId="StyleC">
816+
<w:name w:val="Style C"/>
817+
<w:basedOn w:val="StyleD"/>
818+
</w:style>
819+
<w:style w:type="paragraph" w:styleId="StyleD">
820+
<w:name w:val="Style D"/>
821+
<w:basedOn w:val="StyleE"/>
822+
</w:style>
823+
<w:style w:type="paragraph" w:styleId="StyleE">
824+
<w:name w:val="Style E"/>
825+
</w:style>
826+
</w:styles>"#;
827+
828+
let styles = StyleSheet::parse(xml).unwrap();
829+
830+
let chain = styles.resolve_chain("StyleA");
831+
832+
// Should resolve entire chain
833+
assert_eq!(chain.len(), 5);
834+
assert_eq!(chain[0].id, "StyleA");
835+
assert_eq!(chain[1].id, "StyleB");
836+
assert_eq!(chain[2].id, "StyleC");
837+
assert_eq!(chain[3].id, "StyleD");
838+
assert_eq!(chain[4].id, "StyleE");
839+
}
840+
841+
#[test]
842+
fn test_resolve_chain_missing_base() {
843+
// Style references a base that doesn't exist
844+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
845+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
846+
<w:style w:type="paragraph" w:styleId="Orphan">
847+
<w:name w:val="Orphan Style"/>
848+
<w:basedOn w:val="NonExistent"/>
849+
</w:style>
850+
</w:styles>"#;
851+
852+
let styles = StyleSheet::parse(xml).unwrap();
853+
854+
let chain = styles.resolve_chain("Orphan");
855+
856+
// Should only contain the orphan style (base not found)
857+
assert_eq!(chain.len(), 1);
858+
assert_eq!(chain[0].id, "Orphan");
859+
assert_eq!(chain[0].based_on, Some("NonExistent".to_string()));
860+
}
861+
862+
#[test]
863+
fn test_resolve_chain_empty_for_unknown() {
864+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
865+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
866+
<w:style w:type="paragraph" w:styleId="Normal">
867+
<w:name w:val="Normal"/>
868+
</w:style>
869+
</w:styles>"#;
870+
871+
let styles = StyleSheet::parse(xml).unwrap();
872+
873+
// Unknown style returns empty chain
874+
let chain = styles.resolve_chain("DoesNotExist");
875+
assert!(chain.is_empty());
876+
}
877+
878+
#[test]
879+
fn test_is_heading_and_level() {
880+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
881+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
882+
<w:style w:type="paragraph" w:styleId="Heading1">
883+
<w:name w:val="Heading 1"/>
884+
<w:pPr>
885+
<w:outlineLvl w:val="0"/>
886+
</w:pPr>
887+
</w:style>
888+
<w:style w:type="paragraph" w:styleId="Heading3">
889+
<w:name w:val="Heading 3"/>
890+
<w:pPr>
891+
<w:outlineLvl w:val="2"/>
892+
</w:pPr>
893+
</w:style>
894+
<w:style w:type="paragraph" w:styleId="Normal">
895+
<w:name w:val="Normal"/>
896+
</w:style>
897+
</w:styles>"#;
898+
899+
let styles = StyleSheet::parse(xml).unwrap();
900+
901+
assert!(styles.is_heading("Heading1"));
902+
assert!(styles.is_heading("Heading3"));
903+
assert!(!styles.is_heading("Normal"));
904+
assert!(!styles.is_heading("Unknown"));
905+
906+
assert_eq!(styles.heading_level("Heading1"), Some(1));
907+
assert_eq!(styles.heading_level("Heading3"), Some(3));
908+
assert_eq!(styles.heading_level("Normal"), None);
909+
assert_eq!(styles.heading_level("Unknown"), None);
910+
}
911+
912+
#[test]
913+
fn test_stylesheet_iteration() {
914+
let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
915+
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
916+
<w:style w:type="paragraph" w:styleId="Para1">
917+
<w:name w:val="Paragraph 1"/>
918+
</w:style>
919+
<w:style w:type="table" w:styleId="Table1">
920+
<w:name w:val="Table 1"/>
921+
</w:style>
922+
<w:style w:type="paragraph" w:styleId="Para2">
923+
<w:name w:val="Paragraph 2"/>
924+
</w:style>
925+
</w:styles>"#;
926+
927+
let styles = StyleSheet::parse(xml).unwrap();
928+
929+
// Test paragraph iteration
930+
let para_styles: Vec<_> = styles.paragraph_styles().collect();
931+
assert_eq!(para_styles.len(), 2);
932+
933+
// Test table iteration
934+
let table_styles: Vec<_> = styles.table_styles().collect();
935+
assert_eq!(table_styles.len(), 1);
936+
assert_eq!(table_styles[0].id, "Table1");
937+
}
754938
}

0 commit comments

Comments
 (0)