diff --git a/packages/docx-core/test-primitives/paragraph_id_stability.traceability.test.ts b/packages/docx-core/test-primitives/paragraph_id_stability.traceability.test.ts index db217bee..3b9ba62d 100644 --- a/packages/docx-core/test-primitives/paragraph_id_stability.traceability.test.ts +++ b/packages/docx-core/test-primitives/paragraph_id_stability.traceability.test.ts @@ -98,4 +98,107 @@ describe('Traceability: document-paragraph-id-stability-and-fingerprint — Para }); }, ); + + test.openspec('insertParagraphBookmarks resolves seed collisions with a deterministic salt')( + 'insertParagraphBookmarks resolves seed collisions with a deterministic salt', + async ({ given, when, then, attachPrettyJson }: AllureBddContext) => { + const xmlBody = + 'Anchor context.' + + 'Duplicate clause.' + + 'Tail context.' + + 'Anchor context.' + + 'Duplicate clause.' + + 'Tail context.'; + const doc = makeDoc(xmlBody); + let duplicateIds: (string | null)[] = []; + + await given('two paragraphs have identical text and identical neighbor context', async () => { + await attachPrettyJson('Collision fixture', { + duplicateParagraphIndexes: [1, 4], + duplicateText: 'Duplicate clause.', + previousText: 'Anchor context.', + nextText: 'Tail context.', + }); + }); + + await when('insertParagraphBookmarks is called', async () => { + insertParagraphBookmarks(doc, 'test-attachment'); + const paragraphs = Array.from(doc.getElementsByTagNameNS(OOXML.W_NS, 'p')); + duplicateIds = [paragraphs[1], paragraphs[4]].map((p) => getParagraphBookmarkId(p as Element)); + await attachPrettyJson('Duplicate paragraph identifiers', duplicateIds); + }); + + await then('the colliding paragraphs receive the unsalted hash then the |salt:1 hash', () => { + expect(duplicateIds.length).toBe(2); + expect(duplicateIds[0]).toMatch(/^_bk_[0-9a-f]{12}$/); + expect(duplicateIds[1]).toMatch(/^_bk_[0-9a-f]{12}$/); + expect(duplicateIds[1]).not.toEqual(duplicateIds[0]); + // Pin the exact derivation so the test characterizes the salt-loop, not + // just "two distinct IDs". If buildParagraphSeed later includes sibling + // position or wider context, both IDs would still be distinct without + // the salt loop ever running — this assertion fails loudly in that case. + expect(duplicateIds[0]).toBe('_bk_04c5b72c79f7'); // sha12(seed) + expect(duplicateIds[1]).toBe('_bk_a2abd088979b'); // sha12(seed|salt:1) + }); + }, + ); + + test.openspec('Collision resolution is stable across independent reopens')( + 'Collision resolution is stable across independent reopens', + async ({ given, when, then, attachPrettyJson }: AllureBddContext) => { + const xmlBody = + 'Anchor context.' + + 'Duplicate clause.' + + 'Tail context.' + + 'Anchor context.' + + 'Duplicate clause.' + + 'Tail context.'; + + let firstOpenIds: (string | null)[] = []; + let secondOpenIds: (string | null)[] = []; + + await given('the same colliding paragraph content is opened twice independently', async () => { + await attachPrettyJson('Collision fixture', { + duplicateParagraphIndexes: [1, 4], + duplicateText: 'Duplicate clause.', + previousText: 'Anchor context.', + nextText: 'Tail context.', + }); + }); + + await when('insertParagraphBookmarks is applied to each open', async () => { + const doc1 = makeDoc(xmlBody); + insertParagraphBookmarks(doc1, 'test-attachment'); + const firstParagraphs = Array.from(doc1.getElementsByTagNameNS(OOXML.W_NS, 'p')); + firstOpenIds = [firstParagraphs[1], firstParagraphs[4]].map((p) => + getParagraphBookmarkId(p as Element), + ); + + const doc2 = makeDoc(xmlBody); + insertParagraphBookmarks(doc2, 'test-attachment'); + const secondParagraphs = Array.from(doc2.getElementsByTagNameNS(OOXML.W_NS, 'p')); + secondOpenIds = [secondParagraphs[1], secondParagraphs[4]].map((p) => + getParagraphBookmarkId(p as Element), + ); + + await attachPrettyJson('Duplicate paragraph identifiers by open', { + firstOpenIds, + secondOpenIds, + }); + }); + + await then('collision salts are assigned byte-identically by document order', () => { + expect(firstOpenIds.length).toBe(2); + expect(secondOpenIds).toEqual(firstOpenIds); + for (const id of firstOpenIds) { + expect(id).toMatch(/^_bk_[0-9a-f]{12}$/); + } + expect(firstOpenIds[1]).not.toEqual(firstOpenIds[0]); + // Pin the cross-open salt assignment so any drift in salt-loop iteration + // order (e.g., if derivation later considered prior-document state) + // would fail the test loudly. See companion scenario for the seed math. + expect(firstOpenIds).toEqual(['_bk_04c5b72c79f7', '_bk_a2abd088979b']); + }); + }, + ); });