@@ -2823,4 +2823,351 @@ paragraph = "Normal"
28232823 "Should have bookmark or anchor"
28242824 ) ;
28252825 }
2826+
2827+ // ==================== Sprint 12: Style Contract Resolution Tests ====================
2828+
2829+ #[ test]
2830+ fn test_resolve_heading_style_without_contract ( ) {
2831+ let writer = DocxWriter :: new ( ) ;
2832+
2833+ // Without contract, should fall back to style_map defaults
2834+ assert_eq ! ( writer. resolve_heading_style( 1 ) , "Heading1" ) ;
2835+ assert_eq ! ( writer. resolve_heading_style( 2 ) , "Heading2" ) ;
2836+ assert_eq ! ( writer. resolve_heading_style( 9 ) , "Heading9" ) ;
2837+ }
2838+
2839+ #[ test]
2840+ fn test_resolve_heading_style_with_contract ( ) {
2841+ use crate :: style_map:: { ParagraphStyleMapping , StyleContract } ;
2842+
2843+ let mut contract = StyleContract :: default ( ) ;
2844+ // Headings are stored in paragraph_styles with heading_level set
2845+ contract. paragraph_styles . insert (
2846+ "CustomH1" . to_string ( ) ,
2847+ ParagraphStyleMapping {
2848+ role : "heading" . to_string ( ) ,
2849+ heading_level : Some ( 1 ) ,
2850+ ..Default :: default ( )
2851+ } ,
2852+ ) ;
2853+ contract. paragraph_styles . insert (
2854+ "CustomH2" . to_string ( ) ,
2855+ ParagraphStyleMapping {
2856+ role : "heading" . to_string ( ) ,
2857+ heading_level : Some ( 2 ) ,
2858+ ..Default :: default ( )
2859+ } ,
2860+ ) ;
2861+
2862+ let mut writer = DocxWriter :: new ( ) ;
2863+ writer. set_style_contract ( contract) ;
2864+
2865+ // Should use contract mappings
2866+ assert_eq ! ( writer. resolve_heading_style( 1 ) , "CustomH1" ) ;
2867+ assert_eq ! ( writer. resolve_heading_style( 2 ) , "CustomH2" ) ;
2868+
2869+ // Level 3 not in contract - should fall back to style_map
2870+ assert_eq ! ( writer. resolve_heading_style( 3 ) , "Heading3" ) ;
2871+ }
2872+
2873+ #[ test]
2874+ fn test_resolve_paragraph_style_without_contract ( ) {
2875+ let writer = DocxWriter :: new ( ) ;
2876+
2877+ // Without contract, should fall back to style_map.paragraph()
2878+ assert_eq ! ( writer. resolve_paragraph_style( "body" ) , "Normal" ) ;
2879+ assert_eq ! ( writer. resolve_paragraph_style( "intro" ) , "Normal" ) ;
2880+ }
2881+
2882+ #[ test]
2883+ fn test_resolve_paragraph_style_with_contract ( ) {
2884+ use crate :: style_map:: { ParagraphStyleMapping , StyleContract } ;
2885+
2886+ let mut contract = StyleContract :: default ( ) ;
2887+ // Key is Word style ID, value contains the semantic role
2888+ contract. paragraph_styles . insert (
2889+ "AbstractStyle" . to_string ( ) ,
2890+ ParagraphStyleMapping {
2891+ role : "abstract" . to_string ( ) ,
2892+ ..Default :: default ( )
2893+ } ,
2894+ ) ;
2895+ contract. paragraph_styles . insert (
2896+ "NoteStyle" . to_string ( ) ,
2897+ ParagraphStyleMapping {
2898+ role : "note" . to_string ( ) ,
2899+ ..Default :: default ( )
2900+ } ,
2901+ ) ;
2902+
2903+ let mut writer = DocxWriter :: new ( ) ;
2904+ writer. set_style_contract ( contract) ;
2905+
2906+ // Should use contract mappings (looks up by role, returns Word style ID)
2907+ assert_eq ! ( writer. resolve_paragraph_style( "abstract" ) , "AbstractStyle" ) ;
2908+ assert_eq ! ( writer. resolve_paragraph_style( "note" ) , "NoteStyle" ) ;
2909+
2910+ // Unknown role should fall back
2911+ assert_eq ! ( writer. resolve_paragraph_style( "unknown" ) , "Normal" ) ;
2912+ }
2913+
2914+ #[ test]
2915+ fn test_next_bookmark_id_increments ( ) {
2916+ let mut writer = DocxWriter :: new ( ) ;
2917+
2918+ assert_eq ! ( writer. next_bookmark_id( ) , 0 ) ;
2919+ assert_eq ! ( writer. next_bookmark_id( ) , 1 ) ;
2920+ assert_eq ! ( writer. next_bookmark_id( ) , 2 ) ;
2921+ }
2922+
2923+ #[ test]
2924+ fn test_extract_cover_metadata_with_title_only ( ) {
2925+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
2926+ meta. title = Some ( "My Document Title" . to_string ( ) ) ;
2927+
2928+ let doc = Document {
2929+ metadata : meta,
2930+ intent : None ,
2931+ blocks : vec ! [ ] ,
2932+ } ;
2933+
2934+ let writer = DocxWriter :: new ( ) ;
2935+ let cover_meta = writer. extract_cover_metadata ( & doc) ;
2936+
2937+ assert_eq ! ( cover_meta. title, "My Document Title" ) ;
2938+ assert ! ( cover_meta. author. is_empty( ) ) ;
2939+ assert ! ( cover_meta. revnumber. is_empty( ) ) ;
2940+ }
2941+
2942+ #[ test]
2943+ fn test_extract_cover_metadata_with_authors_vec ( ) {
2944+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
2945+ meta. title = Some ( "Title" . to_string ( ) ) ;
2946+ meta. authors = vec ! [ "John Doe" . to_string( ) , "Jane Smith" . to_string( ) ] ;
2947+
2948+ let doc = Document {
2949+ metadata : meta,
2950+ intent : None ,
2951+ blocks : vec ! [ ] ,
2952+ } ;
2953+
2954+ let writer = DocxWriter :: new ( ) ;
2955+ let cover_meta = writer. extract_cover_metadata ( & doc) ;
2956+
2957+ assert_eq ! ( cover_meta. author, "John Doe, Jane Smith" ) ;
2958+ }
2959+
2960+ #[ test]
2961+ fn test_extract_cover_metadata_with_author_attribute ( ) {
2962+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
2963+ meta. attributes
2964+ . insert ( "author" . to_string ( ) , "Attribute Author" . to_string ( ) ) ;
2965+
2966+ let doc = Document {
2967+ metadata : meta,
2968+ intent : None ,
2969+ blocks : vec ! [ ] ,
2970+ } ;
2971+
2972+ let writer = DocxWriter :: new ( ) ;
2973+ let cover_meta = writer. extract_cover_metadata ( & doc) ;
2974+
2975+ // When authors vec is empty, falls back to attribute
2976+ assert_eq ! ( cover_meta. author, "Attribute Author" ) ;
2977+ }
2978+
2979+ #[ test]
2980+ fn test_extract_cover_metadata_with_revision ( ) {
2981+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
2982+ meta. revision = Some ( "1.0" . to_string ( ) ) ;
2983+ meta. attributes
2984+ . insert ( "revdate" . to_string ( ) , "2025-01-01" . to_string ( ) ) ;
2985+
2986+ let doc = Document {
2987+ metadata : meta,
2988+ intent : None ,
2989+ blocks : vec ! [ ] ,
2990+ } ;
2991+
2992+ let writer = DocxWriter :: new ( ) ;
2993+ let cover_meta = writer. extract_cover_metadata ( & doc) ;
2994+
2995+ assert_eq ! ( cover_meta. revnumber, "1.0" ) ;
2996+ assert_eq ! ( cover_meta. revdate, "2025-01-01" ) ;
2997+ }
2998+
2999+ #[ test]
3000+ fn test_extract_cover_metadata_empty ( ) {
3001+ let doc = Document {
3002+ metadata : utf8dok_ast:: DocumentMeta :: default ( ) ,
3003+ intent : None ,
3004+ blocks : vec ! [ ] ,
3005+ } ;
3006+
3007+ let writer = DocxWriter :: new ( ) ;
3008+ let cover_meta = writer. extract_cover_metadata ( & doc) ;
3009+
3010+ assert ! ( cover_meta. title. is_empty( ) ) ;
3011+ assert ! ( cover_meta. author. is_empty( ) ) ;
3012+ assert ! ( cover_meta. revnumber. is_empty( ) ) ;
3013+ assert ! ( cover_meta. revdate. is_empty( ) ) ;
3014+ }
3015+
3016+ #[ test]
3017+ fn test_update_core_properties_with_no_metadata ( ) {
3018+ use crate :: archive:: OoxmlArchive ;
3019+ use crate :: test_utils:: create_minimal_template;
3020+
3021+ let template = create_minimal_template ( ) ;
3022+ let cursor = Cursor :: new ( & template) ;
3023+ let mut archive = OoxmlArchive :: from_reader ( cursor) . unwrap ( ) ;
3024+
3025+ let doc = Document {
3026+ metadata : utf8dok_ast:: DocumentMeta :: default ( ) ,
3027+ intent : None ,
3028+ blocks : vec ! [ ] ,
3029+ } ;
3030+
3031+ let writer = DocxWriter :: new ( ) ;
3032+ let result = writer. update_core_properties ( & mut archive, & doc) ;
3033+ assert ! ( result. is_ok( ) ) ;
3034+
3035+ // Should not create core.xml when no metadata
3036+ assert ! ( archive. get( "docProps/core.xml" ) . is_none( ) ) ;
3037+ }
3038+
3039+ #[ test]
3040+ fn test_update_core_properties_creates_new ( ) {
3041+ use crate :: archive:: OoxmlArchive ;
3042+ use crate :: test_utils:: create_minimal_template;
3043+
3044+ let template = create_minimal_template ( ) ;
3045+ let cursor = Cursor :: new ( & template) ;
3046+ let mut archive = OoxmlArchive :: from_reader ( cursor) . unwrap ( ) ;
3047+
3048+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
3049+ meta. title = Some ( "New Title" . to_string ( ) ) ;
3050+ meta. authors = vec ! [ "Author Name" . to_string( ) ] ;
3051+
3052+ let doc = Document {
3053+ metadata : meta,
3054+ intent : None ,
3055+ blocks : vec ! [ ] ,
3056+ } ;
3057+
3058+ let writer = DocxWriter :: new ( ) ;
3059+ let result = writer. update_core_properties ( & mut archive, & doc) ;
3060+ assert ! ( result. is_ok( ) ) ;
3061+
3062+ // Should create core.xml
3063+ let core_xml = archive. get_string ( "docProps/core.xml" ) . unwrap ( ) ;
3064+ assert ! ( core_xml. is_some( ) ) ;
3065+
3066+ let content = core_xml. unwrap ( ) ;
3067+ assert ! ( content. contains( "<dc:title>New Title</dc:title>" ) ) ;
3068+ assert ! ( content. contains( "<dc:creator>Author Name</dc:creator>" ) ) ;
3069+ }
3070+
3071+ #[ test]
3072+ fn test_update_core_properties_updates_existing ( ) {
3073+ use crate :: archive:: OoxmlArchive ;
3074+ use crate :: test_utils:: create_minimal_template;
3075+
3076+ let template = create_minimal_template ( ) ;
3077+ let cursor = Cursor :: new ( & template) ;
3078+ let mut archive = OoxmlArchive :: from_reader ( cursor) . unwrap ( ) ;
3079+
3080+ // Pre-populate with existing core.xml
3081+ let existing = r#"<?xml version="1.0"?>
3082+ <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
3083+ xmlns:dc="http://purl.org/dc/elements/1.1/">
3084+ <dc:title>Old Title</dc:title>
3085+ <dc:creator>Old Author</dc:creator>
3086+ </cp:coreProperties>"# ;
3087+ archive. set_string ( "docProps/core.xml" , existing. to_string ( ) ) ;
3088+
3089+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
3090+ meta. title = Some ( "Updated Title" . to_string ( ) ) ;
3091+ meta. authors = vec ! [ "Updated Author" . to_string( ) ] ;
3092+
3093+ let doc = Document {
3094+ metadata : meta,
3095+ intent : None ,
3096+ blocks : vec ! [ ] ,
3097+ } ;
3098+
3099+ let writer = DocxWriter :: new ( ) ;
3100+ let result = writer. update_core_properties ( & mut archive, & doc) ;
3101+ assert ! ( result. is_ok( ) ) ;
3102+
3103+ let core_xml = archive. get_string ( "docProps/core.xml" ) . unwrap ( ) . unwrap ( ) ;
3104+ assert ! ( core_xml. contains( "<dc:title>Updated Title</dc:title>" ) ) ;
3105+ assert ! ( core_xml. contains( "<dc:creator>Updated Author</dc:creator>" ) ) ;
3106+ assert ! ( !core_xml. contains( "Old Title" ) ) ;
3107+ assert ! ( !core_xml. contains( "Old Author" ) ) ;
3108+ }
3109+
3110+ #[ test]
3111+ fn test_update_core_properties_with_revdate ( ) {
3112+ use crate :: archive:: OoxmlArchive ;
3113+ use crate :: test_utils:: create_minimal_template;
3114+
3115+ let template = create_minimal_template ( ) ;
3116+ let cursor = Cursor :: new ( & template) ;
3117+ let mut archive = OoxmlArchive :: from_reader ( cursor) . unwrap ( ) ;
3118+
3119+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
3120+ meta. attributes
3121+ . insert ( "revdate" . to_string ( ) , "2025-06-15" . to_string ( ) ) ;
3122+
3123+ let doc = Document {
3124+ metadata : meta,
3125+ intent : None ,
3126+ blocks : vec ! [ ] ,
3127+ } ;
3128+
3129+ let writer = DocxWriter :: new ( ) ;
3130+ let result = writer. update_core_properties ( & mut archive, & doc) ;
3131+ assert ! ( result. is_ok( ) ) ;
3132+
3133+ let core_xml = archive. get_string ( "docProps/core.xml" ) . unwrap ( ) . unwrap ( ) ;
3134+ // Date should have ISO format with time
3135+ assert ! ( core_xml. contains( "2025-06-15T00:00:00Z" ) ) ;
3136+ }
3137+
3138+ #[ test]
3139+ fn test_update_core_properties_insert_into_existing_without_title ( ) {
3140+ use crate :: archive:: OoxmlArchive ;
3141+ use crate :: test_utils:: create_minimal_template;
3142+
3143+ let template = create_minimal_template ( ) ;
3144+ let cursor = Cursor :: new ( & template) ;
3145+ let mut archive = OoxmlArchive :: from_reader ( cursor) . unwrap ( ) ;
3146+
3147+ // Existing core.xml without title element
3148+ let existing = r#"<?xml version="1.0"?>
3149+ <cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
3150+ xmlns:dc="http://purl.org/dc/elements/1.1/">
3151+ <dc:creator>Existing Author</dc:creator>
3152+ </cp:coreProperties>"# ;
3153+ archive. set_string ( "docProps/core.xml" , existing. to_string ( ) ) ;
3154+
3155+ let mut meta = utf8dok_ast:: DocumentMeta :: default ( ) ;
3156+ meta. title = Some ( "Inserted Title" . to_string ( ) ) ;
3157+
3158+ let doc = Document {
3159+ metadata : meta,
3160+ intent : None ,
3161+ blocks : vec ! [ ] ,
3162+ } ;
3163+
3164+ let writer = DocxWriter :: new ( ) ;
3165+ let result = writer. update_core_properties ( & mut archive, & doc) ;
3166+ assert ! ( result. is_ok( ) ) ;
3167+
3168+ let core_xml = archive. get_string ( "docProps/core.xml" ) . unwrap ( ) . unwrap ( ) ;
3169+ assert ! ( core_xml. contains( "<dc:title>Inserted Title</dc:title>" ) ) ;
3170+ // Existing author should remain
3171+ assert ! ( core_xml. contains( "<dc:creator>Existing Author</dc:creator>" ) ) ;
3172+ }
28263173}
0 commit comments