|
1 | | -import { describe, it, expect, vi, beforeEach } from 'vitest'; |
| 1 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
2 | 2 | import { handleStructuredContentNode } from './handle-structured-content-node'; |
3 | 3 | import { parseAnnotationMarks } from './handle-annotation-node'; |
| 4 | +import { defaultNodeListHandler } from '../../../../../v2/importer/docxImporter.js'; |
| 5 | +import { initTestEditor } from '@tests/helpers/helpers.js'; |
4 | 6 |
|
5 | 7 | // Mock dependencies |
6 | 8 | vi.mock('./handle-annotation-node', () => ({ |
@@ -31,6 +33,10 @@ describe('handleStructuredContentNode', () => { |
31 | 33 | parseAnnotationMarks.mockReturnValue({ marks: [] }); |
32 | 34 | }); |
33 | 35 |
|
| 36 | + afterEach(() => { |
| 37 | + vi.restoreAllMocks(); |
| 38 | + }); |
| 39 | + |
34 | 40 | it('returns null when nodes array is empty', () => { |
35 | 41 | const params = { nodes: [], nodeListHandler: mockNodeListHandler }; |
36 | 42 | const result = handleStructuredContentNode(params); |
@@ -79,7 +85,7 @@ describe('handleStructuredContentNode', () => { |
79 | 85 | const params = { |
80 | 86 | nodes: [node], |
81 | 87 | nodeListHandler: mockNodeListHandler, |
82 | | - path: [], |
| 88 | + path: [{ name: 'w:p' }], |
83 | 89 | }; |
84 | 90 |
|
85 | 91 | parseAnnotationMarks.mockReturnValue({ marks: [{ type: 'bold' }] }); |
@@ -363,3 +369,177 @@ describe('handleStructuredContentNode', () => { |
363 | 369 | }); |
364 | 370 | }); |
365 | 371 | }); |
| 372 | + |
| 373 | +describe('handleStructuredContentNode nested SDT import regression', () => { |
| 374 | + let editor; |
| 375 | + |
| 376 | + const textRun = (text) => ({ |
| 377 | + name: 'w:r', |
| 378 | + elements: [{ name: 'w:t', elements: [{ type: 'text', text }] }], |
| 379 | + }); |
| 380 | + |
| 381 | + const paragraph = (text) => ({ |
| 382 | + name: 'w:p', |
| 383 | + elements: [textRun(text)], |
| 384 | + }); |
| 385 | + |
| 386 | + const sdtPr = ({ id, tag, alias, lockMode = 'unlocked', controlType = 'w:richText' }) => ({ |
| 387 | + name: 'w:sdtPr', |
| 388 | + elements: [ |
| 389 | + { name: 'w:id', attributes: { 'w:val': id } }, |
| 390 | + { name: 'w:tag', attributes: { 'w:val': tag } }, |
| 391 | + { name: 'w:alias', attributes: { 'w:val': alias } }, |
| 392 | + { name: 'w:lock', attributes: { 'w:val': lockMode } }, |
| 393 | + { name: controlType }, |
| 394 | + ], |
| 395 | + }); |
| 396 | + |
| 397 | + const sdt = (props, contentElements) => ({ |
| 398 | + name: 'w:sdt', |
| 399 | + elements: [sdtPr(props), { name: 'w:sdtContent', elements: contentElements }], |
| 400 | + }); |
| 401 | + |
| 402 | + const table = (text) => ({ |
| 403 | + name: 'w:tbl', |
| 404 | + elements: [ |
| 405 | + { |
| 406 | + name: 'w:tblPr', |
| 407 | + elements: [{ name: 'w:tblW', attributes: { 'w:w': '2400', 'w:type': 'dxa' } }], |
| 408 | + }, |
| 409 | + { |
| 410 | + name: 'w:tblGrid', |
| 411 | + elements: [{ name: 'w:gridCol', attributes: { 'w:w': '2400' } }], |
| 412 | + }, |
| 413 | + { |
| 414 | + name: 'w:tr', |
| 415 | + elements: [ |
| 416 | + { |
| 417 | + name: 'w:tc', |
| 418 | + elements: [ |
| 419 | + { |
| 420 | + name: 'w:tcPr', |
| 421 | + elements: [{ name: 'w:tcW', attributes: { 'w:w': '2400', 'w:type': 'dxa' } }], |
| 422 | + }, |
| 423 | + paragraph(text), |
| 424 | + ], |
| 425 | + }, |
| 426 | + ], |
| 427 | + }, |
| 428 | + ], |
| 429 | + }); |
| 430 | + |
| 431 | + const importNodes = (nodes) => { |
| 432 | + const nodeListHandler = defaultNodeListHandler(); |
| 433 | + return nodeListHandler.handler({ |
| 434 | + nodes, |
| 435 | + nodeListHandler, |
| 436 | + docx: {}, |
| 437 | + editor, |
| 438 | + path: [], |
| 439 | + }); |
| 440 | + }; |
| 441 | + |
| 442 | + const expectSchemaValid = (content) => { |
| 443 | + let pmDoc; |
| 444 | + expect(() => { |
| 445 | + pmDoc = editor.schema.nodeFromJSON({ type: 'doc', content }); |
| 446 | + pmDoc.check(); |
| 447 | + }).not.toThrow(); |
| 448 | + return pmDoc; |
| 449 | + }; |
| 450 | + |
| 451 | + const findFirstJson = (node, predicate) => { |
| 452 | + if (!node) return null; |
| 453 | + if (predicate(node)) return node; |
| 454 | + for (const child of node.content || []) { |
| 455 | + const found = findFirstJson(child, predicate); |
| 456 | + if (found) return found; |
| 457 | + } |
| 458 | + return null; |
| 459 | + }; |
| 460 | + |
| 461 | + beforeEach(() => { |
| 462 | + ({ editor } = initTestEditor({ |
| 463 | + isHeadless: true, |
| 464 | + loadFromSchema: true, |
| 465 | + content: { type: 'doc', content: [{ type: 'paragraph' }] }, |
| 466 | + })); |
| 467 | + parseAnnotationMarks.mockReturnValue({ marks: [] }); |
| 468 | + }); |
| 469 | + |
| 470 | + afterEach(() => { |
| 471 | + editor?.destroy(); |
| 472 | + editor = null; |
| 473 | + vi.restoreAllMocks(); |
| 474 | + }); |
| 475 | + |
| 476 | + it('imports nested block SDT when outer sdtContent directly contains w:sdt wrapping a paragraph', () => { |
| 477 | + const inner = sdt({ id: 'inner-block', tag: 'inner-tag', alias: 'Inner Alias', lockMode: 'contentLocked' }, [ |
| 478 | + paragraph('Nested paragraph'), |
| 479 | + ]); |
| 480 | + const outer = sdt({ id: 'outer-block', tag: 'outer-tag', alias: 'Outer Alias', lockMode: 'sdtLocked' }, [inner]); |
| 481 | + |
| 482 | + const result = importNodes([outer]); |
| 483 | + |
| 484 | + expect(result).toHaveLength(1); |
| 485 | + expect(result[0].type).toBe('structuredContentBlock'); |
| 486 | + expect(result[0].attrs).toMatchObject({ |
| 487 | + id: 'outer-block', |
| 488 | + tag: 'outer-tag', |
| 489 | + alias: 'Outer Alias', |
| 490 | + lockMode: 'sdtLocked', |
| 491 | + controlType: 'richText', |
| 492 | + }); |
| 493 | + |
| 494 | + const nested = result[0].content?.[0]; |
| 495 | + expect(nested?.type).toBe('structuredContentBlock'); |
| 496 | + expect(nested.attrs).toMatchObject({ |
| 497 | + id: 'inner-block', |
| 498 | + tag: 'inner-tag', |
| 499 | + alias: 'Inner Alias', |
| 500 | + lockMode: 'contentLocked', |
| 501 | + controlType: 'richText', |
| 502 | + }); |
| 503 | + expect(nested.attrs.sdtPr?.elements?.find((el) => el.name === 'w:alias')?.attributes?.['w:val']).toBe( |
| 504 | + 'Inner Alias', |
| 505 | + ); |
| 506 | + |
| 507 | + expectSchemaValid(result); |
| 508 | + }); |
| 509 | + |
| 510 | + it('wraps nested inline SDT safely when an outer block SDT also contains paragraph and table content', () => { |
| 511 | + const inlineNested = sdt( |
| 512 | + { id: 'inner-inline', tag: 'inline-tag', alias: 'Inline Alias', lockMode: 'sdtContentLocked' }, |
| 513 | + [textRun('Inline value')], |
| 514 | + ); |
| 515 | + const outer = sdt({ id: 'outer-mixed', tag: 'outer-mixed-tag', alias: 'Outer Mixed', lockMode: 'sdtLocked' }, [ |
| 516 | + inlineNested, |
| 517 | + paragraph('Outer paragraph'), |
| 518 | + table('Cell text'), |
| 519 | + ]); |
| 520 | + |
| 521 | + const result = importNodes([outer]); |
| 522 | + |
| 523 | + expect(result).toHaveLength(1); |
| 524 | + expect(result[0].type).toBe('structuredContentBlock'); |
| 525 | + expect(result[0].content?.map((node) => node.type)).toEqual(['paragraph', 'paragraph', 'table']); |
| 526 | + |
| 527 | + const nested = findFirstJson( |
| 528 | + result[0], |
| 529 | + (node) => node.type === 'structuredContent' && node.attrs?.id === 'inner-inline', |
| 530 | + ); |
| 531 | + expect(nested).toBeTruthy(); |
| 532 | + expect(nested.attrs).toMatchObject({ |
| 533 | + id: 'inner-inline', |
| 534 | + tag: 'inline-tag', |
| 535 | + alias: 'Inline Alias', |
| 536 | + lockMode: 'sdtContentLocked', |
| 537 | + controlType: 'richText', |
| 538 | + }); |
| 539 | + expect(nested.attrs.sdtPr?.elements?.find((el) => el.name === 'w:lock')?.attributes?.['w:val']).toBe( |
| 540 | + 'sdtContentLocked', |
| 541 | + ); |
| 542 | + |
| 543 | + expectSchemaValid(result); |
| 544 | + }); |
| 545 | +}); |
0 commit comments