Operand

engine, you in?

gram: docs

> ./packages/core/src/docx/__tests__/block-sdt-fixture.test.ts

/**
* Integration test over the hand-authored comprehensive fixture
* (`e2e/fixtures/block-sdt-comprehensive.docx`, generated by
* `scripts/make-block-sdt-fixture.mjs`). It exercises every block-level
* content-control scenario end to end through the real DOCX pipeline:
* - Phase 1: parse → serialize preserves all controls + unmodeled features
* - Phase 2: parse → toProseDoc → fromProseDoc → serialize (the edit cycle)
* keeps controls and the verbatim raw w:sdtPr, and appends the
* caret-trap trailing paragraph after a doc-final control.
*
* If the fixture is ever regenerated, keep the scenario count/tags in sync.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import JSZip from 'jszip';
import { parseDocumentBody } from '../documentParser';
import { serializeDocumentBody } from '../serializer/documentSerializer';
import { toProseDoc } from '../../prosemirror/conversion/toProseDoc';
import { fromProseDoc } from '../../prosemirror/conversion/fromProseDoc';
import type { BlockContent, BlockSdt, Document } from '../../types/document';
const FIXTURE = join(import.meta.dir, '../../../../../e2e/fixtures/block-sdt-comprehensive.docx');
const TAGS = [
'intro',
'grid',
'multi',
'outer',
'inner',
'locked',
'bound',
'repeat',
'choice',
'endpr',
'last',
];
async function documentXml(): Promise<string> {
const zip = await JSZip.loadAsync(readFileSync(FIXTURE));
const doc = zip.file('word/document.xml');
if (!doc) throw new Error('fixture missing word/document.xml');
return doc.async('string');
}
function countBlockSdt(blocks: BlockContent[]): number {
let n = 0;
for (const b of blocks) {
if (b.type === 'blockSdt') {
n += 1 + countBlockSdt((b as BlockSdt).content);
}
}
return n;
}
describe('comprehensive block-SDT fixture', () => {
test('parses 10 top-level + 1 nested block SDT', async () => {
const body = parseDocumentBody(await documentXml());
expect(body.content.filter((b) => b.type === 'blockSdt').length).toBe(10);
expect(countBlockSdt(body.content)).toBe(11);
});
test('Phase 1 parse → serialize preserves every control and unmodeled feature', async () => {
const out = serializeDocumentBody(parseDocumentBody(await documentXml()));
expect((out.match(/<w:sdt>/g) ?? []).length).toBe(11);
for (const tag of TAGS) expect(out).toContain(`w:val="${tag}"`);
expect(out).toContain('w:dataBinding');
expect(out).toContain('w:storeItemID="{1B2C3D4E-0000-0000-0000-000000000001}"');
expect(out).toContain('w15:repeatingSection');
expect(out).toContain('w:val="sdtContentLocked"');
expect(out).toContain('w:lastValue="2"');
expect(out).toContain('w:displayText="Archived"');
expect(out).toContain('w:sdtEndPr');
});
test('a w15:repeatingSection block control parses as a control, not flattened (#622 eval)', async () => {
// The exact case an evaluator reported as broken on the published 1.x: a
// block-level SDT carrying w15:repeatingSection was flattened to plain
// paragraphs. It must now survive as a blockSdt whose own sdtPr keeps the
// (unmodeled) repeatingSection element verbatim.
const body = parseDocumentBody(await documentXml());
const repeat = body.content.find(
(b): b is BlockSdt => b.type === 'blockSdt' && b.properties.tag === 'repeat'
);
expect(repeat).toBeDefined();
expect(repeat!.content.length).toBeGreaterThan(0); // wraps real content, not dropped
expect(repeat!.properties.rawPropertiesXml).toContain('repeatingSection');
});
test('Phase 2 edit cycle (parse → PM → back → serialize) keeps controls + raw sdtPr', async () => {
const body = parseDocumentBody(await documentXml());
const pm = toProseDoc({ package: { document: body } } as Document);
const out = serializeDocumentBody(fromProseDoc(pm).package.document);
expect((out.match(/<w:sdt>/g) ?? []).length).toBe(11);
// Phase 1's lossless guarantee must hold through the editor round trip.
expect(out).toContain('w:dataBinding');
expect(out).toContain('w15:repeatingSection');
expect(out).toContain('w:val="sdtContentLocked"');
expect(out).toContain('w:lastValue="2"');
// Caret-trap fix: a trailing paragraph follows the doc-final control.
expect(pm.child(pm.childCount - 1).type.name).toBe('paragraph');
});
});