@@ -13,38 +13,49 @@ const FIXTURE_DOC = path.resolve(import.meta.dirname, 'fixtures', 'numwords.docx
1313// OOXML inspection helpers (local to this story)
1414// ---------------------------------------------------------------------------
1515
16+ type ExportedComplexField = {
17+ fieldType : string ;
18+ instruction : string ;
19+ cachedText : string ;
20+ dirty : boolean ;
21+ } ;
22+
23+ type StoryField = {
24+ address ?: unknown ;
25+ fieldType ?: string ;
26+ resolvedText ?: string ;
27+ } ;
28+
1629async function readDocxPart ( docPath : string , partPath : string ) : Promise < string > {
1730 const { stdout } = await execFileAsync ( 'unzip' , [ '-p' , docPath , partPath ] , {
1831 maxBuffer : ZIP_MAX_BUFFER_BYTES ,
1932 } ) ;
2033 return stdout ;
2134}
2235
23- /** Extracts all field instruction texts from a document.xml string. */
24- function extractFieldInstructions ( documentXml : string ) : string [ ] {
25- const matches = [ ...documentXml . matchAll ( / < w : i n s t r T e x t [ ^ > ] * > ( [ ^ < ] * ) < \/ w : i n s t r T e x t > / g) ] ;
26- return matches . map ( ( m ) => m [ 1 ] . trim ( ) ) ;
27- }
28-
29- /** Extracts text elements (w:t) from field cached result runs. */
30- function extractCachedFieldResults ( documentXml : string ) : string [ ] {
31- // Find all w:t elements that appear between w:fldChar separate and end
32- const results : string [ ] = [ ] ;
33- const fieldRegex = / < w : f l d C h a r [ ^ > ] * w : f l d C h a r T y p e = " s e p a r a t e " [ ^ > ] * \/ ? > [ \s \S ] * ?< w : f l d C h a r [ ^ > ] * w : f l d C h a r T y p e = " e n d " / g;
34-
35- for ( const match of documentXml . matchAll ( fieldRegex ) ) {
36- const segment = match [ 0 ] ;
37- const textMatches = [ ...segment . matchAll ( / < w : t [ ^ > ] * > ( [ ^ < ] * ) < \/ w : t > / g) ] ;
38- for ( const tm of textMatches ) {
39- results . push ( tm [ 1 ] ) ;
40- }
36+ function extractExportedComplexFields ( documentXml : string ) : ExportedComplexField [ ] {
37+ const complexFieldPattern =
38+ / < w : f l d C h a r [ ^ > ] * w : f l d C h a r T y p e = " b e g i n " ( [ ^ > ] * ) \/ ? > [ \s \S ] * ?< w : i n s t r T e x t [ ^ > ] * > ( [ ^ < ] * ) < \/ w : i n s t r T e x t > [ \s \S ] * ?< w : f l d C h a r [ ^ > ] * w : f l d C h a r T y p e = " s e p a r a t e " [ ^ > ] * \/ ? > ( [ \s \S ] * ?) < w : f l d C h a r [ ^ > ] * w : f l d C h a r T y p e = " e n d " / g;
39+ const exportedFields : ExportedComplexField [ ] = [ ] ;
40+
41+ for ( const match of documentXml . matchAll ( complexFieldPattern ) ) {
42+ const beginAttributes = match [ 1 ] ?? '' ;
43+ const instruction = ( match [ 2 ] ?? '' ) . trim ( ) ;
44+ const cachedSegment = match [ 3 ] ?? '' ;
45+ const cachedText = [ ...cachedSegment . matchAll ( / < w : t [ ^ > ] * > ( [ ^ < ] * ) < \/ w : t > / g) ]
46+ . map ( ( textMatch ) => textMatch [ 1 ] )
47+ . join ( '' ) ;
48+ const fieldType = instruction . split ( / \s + / ) [ 0 ] ?. toUpperCase ( ) ?? '' ;
49+
50+ exportedFields . push ( {
51+ fieldType,
52+ instruction,
53+ cachedText,
54+ dirty : beginAttributes . includes ( 'w:dirty="true"' ) ,
55+ } ) ;
4156 }
42- return results ;
43- }
4457
45- /** Checks whether w:updateFields is present in settings.xml. */
46- function hasUpdateFields ( settingsXml : string ) : boolean {
47- return / < w : u p d a t e F i e l d s \b [ ^ > ] * w : v a l = " t r u e " / . test ( settingsXml ) ;
58+ return exportedFields ;
4859}
4960
5061/** Extracts a simple element's text value from app.xml. */
@@ -53,11 +64,6 @@ function extractAppStat(appXml: string, tagName: string): string | null {
5364 return match ?. [ 1 ] ?? null ;
5465}
5566
56- /** Checks for w:dirty attribute on fldChar begin elements. */
57- function hasDirtyField ( documentXml : string ) : boolean {
58- return / w : d i r t y = " t r u e " / . test ( documentXml ) ;
59- }
60-
6167// ---------------------------------------------------------------------------
6268// Test helpers
6369// ---------------------------------------------------------------------------
@@ -78,11 +84,28 @@ describe('word-stat-fields roundtrip', () => {
7884 const api = client as any ;
7985
8086 async function openSession ( docPath : string , sessionId : string ) {
81- await api . doc . open ( { filePath : docPath , sessionId } ) ;
87+ await api . doc . open ( { doc : docPath , sessionId } ) ;
8288 }
8389
8490 async function saveSession ( sessionId : string , savePath : string ) {
85- await api . doc . save ( { sessionId, filePath : savePath } ) ;
91+ await api . doc . save ( { sessionId, out : savePath , force : true } ) ;
92+ }
93+
94+ function toStoryField ( item : any ) : StoryField {
95+ return ( item ?. domain ?? item ?? { } ) as StoryField ;
96+ }
97+
98+ async function listFields ( sessionId : string ) : Promise < StoryField [ ] > {
99+ const listResult = unwrap < any > ( await api . doc . fields . list ( { sessionId } ) ) ;
100+ return Array . isArray ( listResult ?. items ) ? listResult . items . map ( toStoryField ) : [ ] ;
101+ }
102+
103+ function listFieldTypes ( items : StoryField [ ] ) : string [ ] {
104+ return items . map ( ( item ) => item . fieldType ?? '' ) ;
105+ }
106+
107+ function findFieldByType ( items : StoryField [ ] , fieldType : string ) : StoryField | undefined {
108+ return items . find ( ( item ) => item . fieldType === fieldType ) ;
86109 }
87110
88111 // ─────────────────────────────────────────────────────────────────────────
@@ -94,14 +117,7 @@ describe('word-stat-fields roundtrip', () => {
94117 const sessionId = sid ( 'phase-a' ) ;
95118 await openSession ( docPath , sessionId ) ;
96119
97- const listResult = await api . doc . fields . list ( { sessionId } ) ;
98- const items = unwrap < any [ ] > ( listResult ) ?. items ?? listResult ?. items ?? [ ] ;
99-
100- // The fixture has NUMWORDS, NUMCHARS, and NUMPAGES fields
101- const fieldTypes = items . map ( ( item : any ) => {
102- const domain = item ?. domain ?? item ;
103- return domain ?. fieldType ;
104- } ) ;
120+ const fieldTypes = listFieldTypes ( await listFields ( sessionId ) ) ;
105121
106122 expect ( fieldTypes ) . toContain ( 'NUMWORDS' ) ;
107123 expect ( fieldTypes ) . toContain ( 'NUMCHARS' ) ;
@@ -124,25 +140,19 @@ describe('word-stat-fields roundtrip', () => {
124140
125141 // Inspect exported document.xml
126142 const documentXml = await readDocxPart ( savedPath , 'word/document.xml' ) ;
143+ const exportedFields = extractExportedComplexFields ( documentXml ) ;
144+ const exportedFieldByType = new Map ( exportedFields . map ( ( field ) => [ field . fieldType , field ] ) ) ;
127145
128- // Should contain field instructions for our stat fields
129- const instructions = extractFieldInstructions ( documentXml ) ;
130- const hasNumwords = instructions . some ( ( instr ) => instr . includes ( 'NUMWORDS' ) ) ;
131- const hasNumchars = instructions . some ( ( instr ) => instr . includes ( 'NUMCHARS' ) ) ;
132- const hasNumpages = instructions . some ( ( instr ) => instr . includes ( 'NUMPAGES' ) ) ;
133-
134- expect ( hasNumwords ) . toBe ( true ) ;
135- expect ( hasNumchars ) . toBe ( true ) ;
136- expect ( hasNumpages ) . toBe ( true ) ;
146+ expect ( exportedFieldByType . has ( 'NUMWORDS' ) ) . toBe ( true ) ;
147+ expect ( exportedFieldByType . has ( 'NUMCHARS' ) ) . toBe ( true ) ;
148+ expect ( exportedFieldByType . has ( 'NUMPAGES' ) ) . toBe ( true ) ;
137149
138150 // Should have fldChar structure (complex fields, not fldSimple)
139151 expect ( documentXml ) . toContain ( 'w:fldCharType="begin"' ) ;
140152 expect ( documentXml ) . toContain ( 'w:fldCharType="separate"' ) ;
141153 expect ( documentXml ) . toContain ( 'w:fldCharType="end"' ) ;
142154
143- // Should have cached result runs between separate and end
144- const cachedResults = extractCachedFieldResults ( documentXml ) ;
145- expect ( cachedResults . length ) . toBeGreaterThanOrEqual ( 3 ) ;
155+ expect ( exportedFields ) . toHaveLength ( 3 ) ;
146156
147157 // Inspect docProps/app.xml — stat values should be present and consistent
148158 const appXml = await readDocxPart ( savedPath , 'docProps/app.xml' ) ;
@@ -161,17 +171,16 @@ describe('word-stat-fields roundtrip', () => {
161171 // Characters (no spaces) must be ≤ CharactersWithSpaces (internal consistency)
162172 expect ( Number ( charsValue ) ) . toBeLessThanOrEqual ( Number ( charsWithSpaces ) ) ;
163173
164- // The NUMWORDS cached result in the field should match the app.xml Words value
165- // (both are computed from the same helper during export)
166- const numwordsCachedResult = cachedResults . find ( ( r ) => r && / ^ \d + $ / . test ( r . trim ( ) ) ) ;
167- if ( numwordsCachedResult ) {
168- expect ( wordsValue ) . toBe ( numwordsCachedResult . trim ( ) ) ;
169- }
174+ expect ( exportedFieldByType . get ( 'NUMWORDS' ) ?. cachedText ) . toBe ( wordsValue ) ;
175+ expect ( exportedFieldByType . get ( 'NUMCHARS' ) ?. cachedText ) . toBe ( charsValue ) ;
176+ expect ( exportedFieldByType . get ( 'NUMPAGES' ) ?. cachedText ) . toBe ( extractAppStat ( appXml , 'Pages' ) ) ;
170177
171178 // Dirty-flag policy: NUMWORDS and NUMCHARS should NOT be dirty (no
172179 // uninterpreted switches). NUMPAGES may or may not be dirty depending
173180 // on whether pagination was available in the test environment.
174181 // We verify the structural invariant rather than a blanket dirty check.
182+ expect ( exportedFieldByType . get ( 'NUMWORDS' ) ?. dirty ) . toBe ( false ) ;
183+ expect ( exportedFieldByType . get ( 'NUMCHARS' ) ?. dirty ) . toBe ( false ) ;
175184 const settingsXml = await readDocxPart ( savedPath , 'word/settings.xml' ) . catch ( ( ) => '' ) ;
176185 if ( settingsXml ) {
177186 expect ( settingsXml ) . toContain ( 'w:settings' ) ;
@@ -190,17 +199,12 @@ describe('word-stat-fields roundtrip', () => {
190199
191200 await openSession ( docPath , sessionId ) ;
192201
193- // Get initial field list
194- const initialList = await api . doc . fields . list ( { sessionId } ) ;
195- const initialItems = unwrap < any [ ] > ( initialList ) ?. items ?? initialList ?. items ?? [ ] ;
196- const numwordsField = initialItems . find ( ( item : any ) => {
197- const domain = item ?. domain ?? item ;
198- return domain ?. fieldType === 'NUMWORDS' ;
199- } ) ;
202+ const initialItems = await listFields ( sessionId ) ;
203+ const numwordsField = findFieldByType ( initialItems , 'NUMWORDS' ) ;
200204
201205 expect ( numwordsField ) . toBeTruthy ( ) ;
202206
203- const initialResolvedText = numwordsField ?. domain ?. resolvedText ?? numwordsField ?. resolvedText ?? '' ;
207+ const initialResolvedText = numwordsField ?. resolvedText ?? '' ;
204208
205209 // Append text to change the word count
206210 await api . doc . create . paragraph ( {
@@ -209,32 +213,25 @@ describe('word-stat-fields roundtrip', () => {
209213 text : 'These extra words change the count significantly' ,
210214 } ) ;
211215
212- // Rebuild the NUMWORDS field
213- const address = numwordsField ?. domain ?. address ?? numwordsField ?. address ;
214- if ( address ) {
215- await api . doc . fields . rebuild ( { sessionId, target : address } ) ;
216+ const address = numwordsField ?. address ;
217+ expect ( address ) . toBeTruthy ( ) ;
216218
217- // Check the value changed
218- const updatedList = await api . doc . fields . list ( { sessionId } ) ;
219- const updatedItems = unwrap < any [ ] > ( updatedList ) ?. items ?? updatedList ?. items ?? [ ] ;
220- const updatedNumwords = updatedItems . find ( ( item : any ) => {
221- const domain = item ?. domain ?? item ;
222- return domain ?. fieldType === 'NUMWORDS' ;
223- } ) ;
219+ await api . doc . fields . rebuild ( { sessionId, target : address } ) ;
224220
225- const updatedResolvedText = updatedNumwords ?. domain ?. resolvedText ?? updatedNumwords ?. resolvedText ?? '' ;
221+ const updatedItems = await listFields ( sessionId ) ;
222+ const updatedNumwords = findFieldByType ( updatedItems , 'NUMWORDS' ) ;
223+ const updatedResolvedText = updatedNumwords ?. resolvedText ?? '' ;
226224
227- // After adding words, the count should be different from the original
228- expect ( updatedResolvedText ) . not . toBe ( initialResolvedText ) ;
229- }
225+ // After adding words, the count should be different from the original
226+ expect ( updatedResolvedText ) . not . toBe ( initialResolvedText ) ;
230227
231228 // Save and re-inspect OOXML
232229 const savedPath = outPath ( 'phase-d-exported.docx' ) ;
233230 await saveSession ( sessionId , savedPath ) ;
234231
235232 const documentXml = await readDocxPart ( savedPath , 'word/document.xml' ) ;
236- const instructions = extractFieldInstructions ( documentXml ) ;
237- expect ( instructions . some ( ( instr ) => instr . includes ( 'NUMWORDS' ) ) ) . toBe ( true ) ;
233+ const exportedFields = extractExportedComplexFields ( documentXml ) ;
234+ expect ( exportedFields . some ( ( field ) => field . fieldType === 'NUMWORDS' ) ) . toBe ( true ) ;
238235
239236 await api . doc . close ( { sessionId, discard : true } ) ;
240237 } ) ;
@@ -255,14 +252,7 @@ describe('word-stat-fields roundtrip', () => {
255252 // Reopen the exported file
256253 const secondSessionId = sid ( 'phase-e-second' ) ;
257254 await openSession ( firstSavedPath , secondSessionId ) ;
258-
259- const listResult = await api . doc . fields . list ( { sessionId : secondSessionId } ) ;
260- const items = unwrap < any [ ] > ( listResult ) ?. items ?? listResult ?. items ?? [ ] ;
261-
262- const fieldTypes = items . map ( ( item : any ) => {
263- const domain = item ?. domain ?? item ;
264- return domain ?. fieldType ;
265- } ) ;
255+ const fieldTypes = listFieldTypes ( await listFields ( secondSessionId ) ) ;
266256
267257 // Fields should still be discoverable after roundtrip
268258 expect ( fieldTypes ) . toContain ( 'NUMWORDS' ) ;
0 commit comments