diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.alignment.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.alignment.test.ts new file mode 100644 index 0000000000..d47d1cdaf0 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/HeaderFooterSessionManager.alignment.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FlowBlock, HeaderFooterLayout, Layout, Measure, ParaFragment } from '@superdoc/contracts'; +import type { HeaderFooterLayoutResult } from '@superdoc/layout-bridge'; + +import type { Editor } from '../../Editor.js'; +import { + HeaderFooterSessionManager, + type SessionManagerDependencies, +} from '../header-footer/HeaderFooterSessionManager.js'; + +/* + * These tests exercise the alignment contract between the painter's + * decoration fragments and their resolved items. + * + * Contract: payload.items[i] corresponds to payload.fragments[i]. The producer + * guarantees this by construction (both come from the same HeaderFooterLayoutResult). + * When the contract is violated, a runtime guard warns and drops items. + * + * Covered paths: + * 1. rId-based (multi-section) — sister path to the variant-based one already + * covered in HeaderFooterSessionManager.test.ts. + * 2. Length-mismatch guard — proves that the last-line safety net fires when + * resolved/fragment counts diverge. + */ + +type LayoutPerRIdFn = ( + input: unknown, + layout: unknown, + sections: unknown, + handles: { headerLayoutsByRId: Map }, +) => Promise; + +const { mockInitHeaderFooterRegistry, mockLayoutPerRIdHeaderFooters } = vi.hoisted(() => ({ + mockInitHeaderFooterRegistry: vi.fn(), + mockLayoutPerRIdHeaderFooters: vi.fn(), +})); + +vi.mock('../../header-footer/HeaderFooterRegistryInit.js', () => ({ + initHeaderFooterRegistry: mockInitHeaderFooterRegistry, +})); + +vi.mock('../../header-footer/HeaderFooterPerRidLayout.js', () => ({ + layoutPerRIdHeaderFooters: mockLayoutPerRIdHeaderFooters, +})); + +// Import AFTER mocks so the module resolves to our mocked versions. +import * as layoutResolved from '@superdoc/layout-resolved'; + +function createMainEditorStub(): Editor { + return { isEditable: true, view: { focus: vi.fn() } } as unknown as Editor; +} + +function buildHeaderResult(blockId: string): HeaderFooterLayoutResult { + const paraFragment: ParaFragment = { + kind: 'para', + blockId, + fromLine: 0, + toLine: 1, + x: 72, + y: 10, + width: 468, + }; + const layout: HeaderFooterLayout = { + height: 50, + pages: [{ number: 1, fragments: [paraFragment] }], + }; + const blocks: FlowBlock[] = [{ kind: 'paragraph', id: blockId, runs: [] }]; + const measures: Measure[] = [ + { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 5, width: 100, ascent: 10, descent: 3, lineHeight: 18 }], + totalHeight: 18, + }, + ]; + return { kind: 'header', type: 'default', layout, blocks, measures }; +} + +function buildDeps(): SessionManagerDependencies { + return { + getLayoutOptions: vi.fn(() => ({})), + getPageElement: vi.fn(() => null), + scrollPageIntoView: vi.fn(), + waitForPageMount: vi.fn(async () => true), + convertPageLocalToOverlayCoords: vi.fn(() => ({ x: 0, y: 0 })), + isViewLocked: vi.fn(() => false), + getBodyPageHeight: vi.fn(() => 800), + notifyInputBridgeTargetChanged: vi.fn(), + scheduleRerender: vi.fn(), + setPendingDocChange: vi.fn(), + getBodyPageCount: vi.fn(() => 1), + }; +} + +function buildLayoutWithRId(rId: string): Layout { + return { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + sectionIndex: 0, + sectionRefs: { headerRefs: { default: rId } }, + } as never, + ], + } as unknown as Layout; +} + +function buildLayoutForVariant(): Layout { + return { + version: 1, + flowMode: 'paginated', + pageGap: 0, + pageSize: { w: 612, h: 792 }, + pages: [{ number: 1, margins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 } } as never], + } as unknown as Layout; +} + +describe('HeaderFooterSessionManager — decoration item alignment', () => { + let manager: HeaderFooterSessionManager; + let painterHost: HTMLElement; + let visibleHost: HTMLElement; + let selectionOverlay: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + mockLayoutPerRIdHeaderFooters.mockImplementation(async () => { + /* default no-op; specific tests override via mockImplementationOnce */ + }); + + painterHost = document.createElement('div'); + visibleHost = document.createElement('div'); + selectionOverlay = document.createElement('div'); + document.body.appendChild(painterHost); + document.body.appendChild(visibleHost); + document.body.appendChild(selectionOverlay); + }); + + afterEach(() => { + manager?.destroy(); + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + function createManager(): HeaderFooterSessionManager { + const m = new HeaderFooterSessionManager({ + painterHost, + visibleHost, + selectionOverlay, + editor: createMainEditorStub(), + defaultPageSize: { w: 612, h: 792 }, + defaultMargins: { top: 72, right: 72, bottom: 72, left: 72, header: 36, footer: 36 }, + }); + m.setDependencies(buildDeps()); + m.headerFooterIdentifier = { + headerIds: { default: 'rId-header-default', first: null, even: null, odd: null }, + footerIds: { default: null, first: null, even: null, odd: null }, + titlePg: false, + alternateHeaders: false, + }; + return m; + } + + it('rId-based path: delivers items aligned 1:1 with fragments', async () => { + // Arrange — seed headerLayoutsByRId via the mocked layoutPerRIdHeaderFooters; + // the real resolveHeaderFooterLayout runs downstream and populates the resolved map. + const rId = 'rId-header-default'; + mockLayoutPerRIdHeaderFooters.mockImplementationOnce(async (_input, _layout, _sections, handles) => { + (handles as { headerLayoutsByRId: Map }).headerLayoutsByRId.set( + rId, + buildHeaderResult('p1'), + ); + }); + manager = createManager(); + const layout = buildLayoutWithRId(rId); + await manager.layoutPerRId({} as never, layout, [] as never); + + // Act + const provider = manager.createDecorationProvider('header', layout); + const payload = provider!(1, layout.pages[0]!.margins, layout.pages[0]); + + // Assert — resolved items arrive, same length as fragments, same blockId + expect(payload).not.toBeNull(); + expect(payload!.fragments).toHaveLength(1); + expect(payload!.items).toBeDefined(); + expect(payload!.items!.length).toBe(payload!.fragments.length); + const firstItem = payload!.items![0]!; + // All non-group resolved paint items carry blockId; narrow by kind. + if (firstItem.kind !== 'fragment') throw new Error(`expected fragment item, got ${firstItem.kind}`); + expect(firstItem.blockId).toBe('p1'); + }); + + it('length-mismatch guard: drops items and warns when resolved length diverges', () => { + // Arrange — stub the resolver to produce TWO items for ONE fragment (a divergence + // that can only happen if the resolver itself is buggy or the upstream data drifts). + const divergentLayout = { + height: 50, + pages: [ + { + number: 1, + items: [ + { kind: 'fragment', fragmentKind: 'para', blockId: 'p1', id: 'a', pageIndex: 0 } as never, + { kind: 'fragment', fragmentKind: 'para', blockId: 'p1', id: 'b', pageIndex: 0 } as never, + ], + }, + ], + }; + const resolverSpy = vi.spyOn(layoutResolved, 'resolveHeaderFooterLayout').mockReturnValue(divergentLayout as never); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + manager = createManager(); + manager.setLayoutResults([buildHeaderResult('p1')], null); + + // Act + const layout = buildLayoutForVariant(); + const payload = manager.createDecorationProvider('header', layout)!(1, layout.pages[0]!.margins, layout.pages[0]); + + // Assert — items dropped, fragments unchanged, guard warning emitted once + expect(payload).not.toBeNull(); + expect(payload!.fragments).toHaveLength(1); + expect(payload!.items).toBeUndefined(); + const guardCalls = warnSpy.mock.calls.filter((args) => String(args[0]).includes('Resolved items length')); + expect(guardCalls).toHaveLength(1); + expect(String(guardCalls[0][0])).toContain('does not match fragments length'); + + resolverSpy.mockRestore(); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts index 720e979de7..604dc62d25 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.collaboration.test.ts @@ -184,11 +184,6 @@ vi.mock('y-prosemirror', () => ({ relativePositionToAbsolutePosition: mockRelativePositionToAbsolutePosition, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - /** * Create a mock Awareness instance for testing collaboration features * @returns {Awareness} Mock awareness instance diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts index 37b37bfa45..d782b50930 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.decorationSync.test.ts @@ -278,11 +278,6 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - /** * Integration tests for decoration bridge sync via PresentationEditor. * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts index 3b70896003..ce52d8ea6a 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.draggableFocus.test.ts @@ -200,11 +200,6 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('PresentationEditor - Draggable Annotation Focus Suppression (SD-1179)', () => { let container: HTMLElement; let editor: PresentationEditor; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts index edc977cfd5..d918411848 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.focusWrapping.test.ts @@ -215,11 +215,6 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('PresentationEditor - Focus Wrapping (#wrapHiddenEditorFocus)', () => { let container: HTMLElement; let editor: PresentationEditor; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts index 43952f86e3..d25754094d 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getCurrentPageIndex.test.ts @@ -268,11 +268,6 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - /** * Test suite for PresentationEditor.#getCurrentPageIndex() fragment fallback * diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts index 44b8833b16..ddd056ea64 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.getElementAtPos.test.ts @@ -182,11 +182,6 @@ vi.mock('../../header-footer/EditorOverlayManager.js', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('PresentationEditor.getElementAtPos', () => { let container: HTMLElement; let editor: PresentationEditor; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts index b75b0eb138..0699caa0ca 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.goToAnchor.test.ts @@ -256,11 +256,6 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('PresentationEditor - goToAnchor', () => { let container: HTMLElement; let editor: PresentationEditor; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts index a1dafcff95..1cabb91ffe 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.media.test.ts @@ -126,11 +126,6 @@ vi.mock('y-prosemirror', () => ({ relativePositionToAbsolutePosition: vi.fn((relPos) => relPos?.pos ?? null), })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('SD-1313: toFlowBlocks receives media from storage.image.media', () => { let editor: PresentationEditor; let container: HTMLElement; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts index 08aea2d32f..c10b8d8711 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.scrollToPosition.test.ts @@ -273,11 +273,6 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('PresentationEditor - scrollToPosition', () => { let container: HTMLElement; let editor: PresentationEditor; diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts index e15d1eb62f..a68dcc4abc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.test.ts @@ -315,11 +315,6 @@ vi.mock('@superdoc/measuring-dom', () => ({ measureBlock: mockMeasureBlock, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: mockResolveLayout, - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - vi.mock('@extensions/pagination/pagination-helpers.js', () => ({ createHeaderFooterEditor: mockCreateHeaderFooterEditor, onHeaderFooterDataUpdate: mockOnHeaderFooterDataUpdate, diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts index 4020a94542..7a89fa6288 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/tests/PresentationEditor.zoom.test.ts @@ -287,11 +287,6 @@ vi.mock('../../header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), - resolveHeaderFooterLayout: vi.fn(() => ({ height: 0, pages: [] })), -})); - describe('PresentationEditor - Zoom Functionality', () => { let container: HTMLElement; let editor: PresentationEditor; diff --git a/packages/super-editor/src/index.types.test.ts b/packages/super-editor/src/index.types.test.ts index 39f9cfe5e9..15dc5bacc9 100644 --- a/packages/super-editor/src/index.types.test.ts +++ b/packages/super-editor/src/index.types.test.ts @@ -667,10 +667,6 @@ vi.mock('./editors/v1/core/header-footer/EditorOverlayManager', () => ({ EditorOverlayManager: mockEditorOverlayManager, })); -vi.mock('@superdoc/layout-resolved', () => ({ - resolveLayout: vi.fn(() => ({ version: 1, flowMode: 'paginated', pageGap: 0, pages: [] })), -})); - // ============================================ // TYPE VERIFICATION TESTS // ============================================