import { describe, expect, it, vi } from 'vitest'; import { parseHTML } from 'linkedom'; import { DEFAULT_FILE_VIEWER_RENDER_TARGET_CLASS, FILE_VIEWER_RENDER_SESSION_DISPOSE_ERROR_MESSAGE, WorkerRefImpl, applyFileViewerRenderSurfaceState, clearFileViewerRenderSurface, createFileRenderHandlerRendererSession, createFileRenderHandlerRegistry, createFileRenderHandlerLoader, createFileViewerRenderSurfaceActionHandlers, createFileViewerRenderReadinessTarget, createFileViewerRenderSurfaceState, createFileViewerRenderSurfaceStateTarget, createFileViewerRenderTarget, createFileViewerRendererDispatcher, disposeActiveFileViewerRendererSession, disposeFileViewerRendered, disposeFileViewerRendererSession, normalizeSource, removeFileViewerRenderTarget, resetFileViewerRenderSurface, renderFileViewerHandler, runFileViewerRenderSurfaceClear, runFileViewerRenderSurfaceMount, resolveFileViewerRenderSessionDisposeErrorMessage, reportFileViewerRenderSessionDisposeError, refWorker, type FileRenderContext, type FileRenderHandler, type RendererDefinition, } from '../packages/core/src'; describe('@file-viewer/core worker and render contracts', () => { it('creates worker refs lazily and reuses the created worker', () => { const worker = { terminate: vi.fn() } as unknown as Worker; const provider = vi.fn(() => worker); const ref = refWorker('demo.worker.js'); expect(ref.name).toBe('demo.worker.js'); expect(ref.worker).toBeNull(); expect(ref.defaults(provider)).toBe(worker); expect(ref.defaults(provider)).toBe(worker); expect(provider).toHaveBeenCalledTimes(1); }); it('keeps WorkerRefImpl constructible for compatibility wrappers', () => { const worker = { postMessage: vi.fn() } as unknown as Worker; const ref = new WorkerRefImpl('compat.worker.js', worker); expect(ref.name).toBe('compat.worker.js'); expect(ref.defaults(() => { throw new Error('should not replace existing worker'); })).toBe(worker); }); it('keeps the legacy one-argument WorkerRefImpl constructor working', () => { const worker = { postMessage: vi.fn() } as unknown as Worker; const ref = new WorkerRefImpl(worker); expect(ref.name).toBe(''); expect(ref.defaults(() => { throw new Error('should not replace existing worker'); })).toBe(worker); }); it('exposes renderer context and handler contracts from core', async () => { const context: FileRenderContext = { filename: 'demo.pdf', url: '/demo.pdf', streamUrl: '/demo.pdf', onProgressiveRender: vi.fn(), registerExportAdapter: vi.fn(), }; const handler: FileRenderHandler = async (_buffer, target, type, nextContext) => { nextContext?.onProgressiveRender?.(); target.dataset.renderedType = type || ''; return nextContext?.filename || ''; }; const target = { dataset: {} } as HTMLDivElement; await expect(handler(new ArrayBuffer(1), target, 'pdf', context)).resolves.toBe('demo.pdf'); expect(target.dataset.renderedType).toBe('pdf'); expect(context.onProgressiveRender).toHaveBeenCalledTimes(1); }); it('invokes render handlers through the core dispatcher contract', async () => { const context: FileRenderContext = { filename: 'demo.pdf', onProgressiveRender: vi.fn(), }; const handler: FileRenderHandler = async (_buffer, target, type, nextContext) => { nextContext?.onProgressiveRender?.(); target.dataset.renderedType = type || ''; return nextContext?.filename || ''; }; const dispatcher = createFileViewerRendererDispatcher({ registry: { list: () => [{ id: 'pdf', label: 'PDF', category: 'document', extensions: ['pdf'], } as RendererDefinition], register: () => undefined, unregister: () => false, getById: () => undefined, getByExtension: () => undefined, hasExtension: () => false, listExtensions: () => [], }, handlers: [{ rendererId: 'pdf', handler }], }); const target = { dataset: {} } as HTMLDivElement; await expect(renderFileViewerHandler({ dispatcher, buffer: new ArrayBuffer(1), target, type: '.PDF', context, })).resolves.toBe('demo.pdf'); expect(target.dataset.renderedType).toBe('pdf'); expect(context.onProgressiveRender).toHaveBeenCalledTimes(1); }); it('adapts legacy render handlers into renderer loaders', async () => { const unmount = vi.fn(); const onProgressiveRender = vi.fn(); const registerExportAdapter = vi.fn(); const handler: FileRenderHandler<{ unmount: () => void }, HTMLDivElement> = async (_buffer, target, type, context) => { context?.onProgressiveRender?.(); target.dataset.renderedType = type || ''; target.dataset.filename = context?.filename || ''; target.dataset.streamUrl = context?.streamUrl || ''; return { unmount }; }; const loader = createFileRenderHandlerLoader({ handler }); const target = { dataset: {} } as HTMLDivElement; const session = await loader({ source: normalizeSource({ buffer: new ArrayBuffer(1), filename: 'demo.docx', }), surface: { container: target }, options: {}, registerExportAdapter, renderContext: { filename: 'legacy.docx', streamUrl: '/legacy-stream.docx', onProgressiveRender, }, }); expect(target.dataset.renderedType).toBe('docx'); expect(target.dataset.filename).toBe('legacy.docx'); expect(target.dataset.streamUrl).toBe('/legacy-stream.docx'); expect(onProgressiveRender).toHaveBeenCalledTimes(1); await session.destroy?.(); expect(unmount).toHaveBeenCalledTimes(1); const destroy = vi.fn(); disposeFileViewerRendered({ destroy }); expect(destroy).toHaveBeenCalledTimes(1); }); it('wraps legacy rendered instances into core renderer sessions', async () => { const unmount = vi.fn(); const rendered = { unmount }; const session = createFileRenderHandlerRendererSession(rendered); expect(session.rendered).toBe(rendered); await session.destroy?.(); expect(unmount).toHaveBeenCalledTimes(1); }); it('creates and clears framework-neutral render surface targets', () => { const { document } = parseHTML('
old
'); const root = document.getElementById('root') as HTMLElement; clearFileViewerRenderSurface(root); expect(root.childElementCount).toBe(0); const target = createFileViewerRenderTarget(root); expect(target.className).toBe(DEFAULT_FILE_VIEWER_RENDER_TARGET_CLASS); expect(root.firstElementChild).toBe(target); const customTarget = createFileViewerRenderTarget(root, { className: 'custom-render' }); expect(customTarget.className).toBe('custom-render'); expect(root.childElementCount).toBe(2); expect(removeFileViewerRenderTarget(root, customTarget)).toBe(true); expect(removeFileViewerRenderTarget(root, customTarget)).toBe(false); }); it('applies framework-neutral render surface state and disposes the active session', () => { const { document } = parseHTML('
rendered
'); const root = document.getElementById('root') as HTMLElement; const onError = vi.fn(); const destroy = vi.fn(); const session = { destroy }; const adapter = { print: true }; const state = createFileViewerRenderSurfaceState(); const readiness = { renderedReady: true, progressiveReady: true, }; expect(state.session).toBeNull(); expect(state.exportAdapter).toBeNull(); expect(applyFileViewerRenderSurfaceState(state, { session, exportAdapter: adapter, })).toBe(state); expect(state.session).toBe(session); expect(state.exportAdapter).toBe(adapter); expect(disposeActiveFileViewerRendererSession(state, { onError })).toBe(session); expect(destroy).toHaveBeenCalledTimes(1); expect(onError).not.toHaveBeenCalled(); expect(state.session).toBeNull(); expect(state.exportAdapter).toBe(adapter); applyFileViewerRenderSurfaceState(state, { exportAdapter: null }); expect(state.exportAdapter).toBeNull(); applyFileViewerRenderSurfaceState(state, { session, exportAdapter: adapter, }); expect(resetFileViewerRenderSurface({ surfaceState: state, readinessState: readiness, container: root, disposeOptions: { onError }, })).toBe(session); expect(destroy).toHaveBeenCalledTimes(2); expect(onError).not.toHaveBeenCalled(); expect(state.session).toBeNull(); expect(state.exportAdapter).toBeNull(); expect(readiness).toEqual({ renderedReady: false, progressiveReady: false, }); expect(root.childElementCount).toBe(0); }); it('creates render surface targets from framework accessors', () => { const { document } = parseHTML('
rendered
'); const root = document.getElementById('root') as HTMLElement; const destroy = vi.fn(); const session = { destroy }; const adapter = { exportHtml: true }; const stateBacking: { session: typeof session | null; exportAdapter: typeof adapter | null; } = { session: null, exportAdapter: null, }; const readinessBacking = { renderedReady: true, progressiveReady: true, }; const state = createFileViewerRenderSurfaceStateTarget({ session: { get: () => stateBacking.session, set: value => { stateBacking.session = value; }, }, exportAdapter: { get: () => stateBacking.exportAdapter, set: value => { stateBacking.exportAdapter = value as typeof adapter | null; }, }, }); const readiness = createFileViewerRenderReadinessTarget({ renderedReady: { get: () => readinessBacking.renderedReady, set: value => { readinessBacking.renderedReady = value; }, }, progressiveReady: { get: () => readinessBacking.progressiveReady, set: value => { readinessBacking.progressiveReady = value; }, }, }); expect(applyFileViewerRenderSurfaceState(state, { session, exportAdapter: adapter, })).toBe(state); expect(stateBacking).toEqual({ session, exportAdapter: adapter, }); expect(resetFileViewerRenderSurface({ surfaceState: state, readinessState: readiness, container: root, })).toBe(session); expect(destroy).toHaveBeenCalledTimes(1); expect(stateBacking).toEqual({ session: null, exportAdapter: null, }); expect(readinessBacking).toEqual({ renderedReady: false, progressiveReady: false, }); expect(root.childElementCount).toBe(0); }); it('runs framework-neutral render surface clear lifecycle orchestration', () => { const { document } = parseHTML('
rendered
'); const root = document.getElementById('root') as HTMLElement; const session = { destroy: vi.fn() }; const state = createFileViewerRenderSurfaceState(); const readiness = { renderedReady: true, progressiveReady: true, }; const events: string[] = []; const unloadContext = { phase: 'unload-start' as const }; applyFileViewerRenderSurfaceState(state, { session, exportAdapter: { exportHtml: true }, }); const result = runFileViewerRenderSurfaceClear({ reason: 'component-unmount', surfaceState: state, readinessState: readiness, container: root, onUnloadStart: reason => { events.push(`start:${reason}:${root.childElementCount}`) return unloadContext; }, onClearActiveDocumentContext: () => events.push(`clear-active:${root.childElementCount}`), onClearDocumentState: () => events.push('clear-doc'), onStopZoomObserver: () => events.push('stop-zoom'), onClearZoomProvider: () => events.push('clear-zoom'), onUnloadComplete: (context, reason) => events.push(`complete:${reason}:${context === unloadContext}`), }); expect(result).toEqual({ reason: 'component-unmount', unloadContext, session, }); expect(session.destroy).toHaveBeenCalledTimes(1); expect(state.session).toBeNull(); expect(state.exportAdapter).toBeNull(); expect(readiness).toEqual({ renderedReady: false, progressiveReady: false, }); expect(root.childElementCount).toBe(0); expect(events).toEqual([ 'start:component-unmount:1', 'clear-active:0', 'clear-doc', 'stop-zoom', 'clear-zoom', 'complete:component-unmount:true', ]); }); it('runs framework-neutral render surface mount orchestration', async () => { const { document } = parseHTML('
old
'); const root = document.getElementById('root') as HTMLElement; const session = { destroy: vi.fn() }; const adapter = { exportHtml: true }; const state = createFileViewerRenderSurfaceState(); const readiness = { renderedReady: false, progressiveReady: false, }; const clearRenderedContent = vi.fn(() => clearFileViewerRenderSurface(root)); const waitForContainer = vi.fn(); const waitForPaint = vi.fn(); const startZoomObserver = vi.fn(); const refreshDocumentIndex = vi.fn(); const refreshZoomProvider = vi.fn(); const render = vi.fn(async context => { expect(context.type).toBe('pdf'); expect(context.filename).toBe('demo.pdf'); expect(context.sourceUrl).toBe('/example/demo.pdf'); expect(context.streamUrl).toBe('/stream/demo.pdf'); expect(context.target.className).toBe(DEFAULT_FILE_VIEWER_RENDER_TARGET_CLASS); context.registerExportAdapter(adapter); context.onProgressiveRender(); context.target.appendChild(document.createElement('strong')); return session; }); const result = await runFileViewerRenderSurfaceMount({ buffer: new ArrayBuffer(4), file: new File(['demo'], 'demo.pdf'), version: 1, sourceUrl: '/example/demo.pdf', streamUrl: '/stream/demo.pdf', getContainer: () => root, surfaceState: state, readinessState: readiness, isCurrent: version => version === 1, clearRenderedContent, render, waitForContainer, waitForPaint, onStartZoomObserver: startZoomObserver, onRefreshDocumentIndex: refreshDocumentIndex, onRefreshZoomProvider: refreshZoomProvider, }); expect(result).toBe(session); expect(clearRenderedContent).toHaveBeenCalledWith('replace'); expect(waitForContainer).toHaveBeenCalledTimes(1); expect(waitForPaint).toHaveBeenCalledTimes(1); expect(startZoomObserver).toHaveBeenCalledTimes(1); expect(render).toHaveBeenCalledTimes(1); expect(root.innerHTML).not.toContain('old'); expect(root.innerHTML).toContain('file-render'); expect(root.innerHTML).toContain('strong'); expect(state.exportAdapter).toBe(adapter); expect(readiness.progressiveReady).toBe(true); expect(refreshDocumentIndex).toHaveBeenCalledTimes(1); expect(refreshZoomProvider).toHaveBeenCalledTimes(1); }); it('creates reusable render surface action handlers for framework wrappers', async () => { const { document } = parseHTML('
old
'); const root = document.getElementById('root') as HTMLElement; const staleSession = { destroy: vi.fn() }; const session = { destroy: vi.fn() }; const adapter = { exportHtml: true }; const state = createFileViewerRenderSurfaceState(); const readiness = { renderedReady: true, progressiveReady: true, }; const waitForContainer = vi.fn(); const waitForPaint = vi.fn(); const startZoomObserver = vi.fn(); const stopZoomObserver = vi.fn(); const clearZoomProvider = vi.fn(); const refreshDocumentIndex = vi.fn(); const refreshZoomProvider = vi.fn(); const events: string[] = []; const unloadContext = { phase: 'facade-unload' as const }; const render = vi.fn(async context => { expect(context.type).toBe('pdf'); expect(context.filename).toBe('facade.pdf'); context.registerExportAdapter(adapter); context.onProgressiveRender(); context.target.appendChild(document.createElement('strong')); return session; }); const actions = createFileViewerRenderSurfaceActionHandlers({ getContainer: () => root, surfaceState: state, readinessState: readiness, isCurrent: version => version === 1, waitForContainer, waitForPaint, onUnloadStart: reason => { events.push(`start:${reason}`); return unloadContext; }, onUnloadComplete: (context, reason) => { events.push(`complete:${reason}:${context === unloadContext}`); }, onClearActiveDocumentContext: () => events.push('clear-active'), onClearDocumentState: () => events.push('clear-doc'), onStartZoomObserver: startZoomObserver, onStopZoomObserver: stopZoomObserver, onClearZoomProvider: clearZoomProvider, onRefreshDocumentIndex: refreshDocumentIndex, onRefreshZoomProvider: refreshZoomProvider, render, }); expect(actions.setActiveRenderSession(staleSession)).toBe(state); expect(state.session).toBe(staleSession); const clearResult = actions.clearRenderedContent('replace'); expect(clearResult).toEqual({ reason: 'replace', unloadContext, session: staleSession, }); expect(staleSession.destroy).toHaveBeenCalledTimes(1); expect(state.session).toBeNull(); expect(state.exportAdapter).toBeNull(); expect(readiness).toEqual({ renderedReady: false, progressiveReady: false, }); expect(root.childElementCount).toBe(0); root.appendChild(document.createElement('em')); const result = await actions.mountRenderedContent( new ArrayBuffer(4), new File(['demo'], 'facade.pdf'), 1, '/example/facade.pdf', '/stream/facade.pdf' ); expect(result).toBe(session); expect(render).toHaveBeenCalledTimes(1); expect(root.innerHTML).not.toContain('em'); expect(root.innerHTML).toContain('file-render'); expect(root.innerHTML).toContain('strong'); expect(state.exportAdapter).toBe(adapter); expect(readiness.progressiveReady).toBe(true); expect(waitForContainer).toHaveBeenCalledTimes(1); expect(waitForPaint).toHaveBeenCalledTimes(1); expect(startZoomObserver).toHaveBeenCalledTimes(1); expect(stopZoomObserver).toHaveBeenCalledTimes(2); expect(clearZoomProvider).toHaveBeenCalledTimes(2); expect(refreshDocumentIndex).toHaveBeenCalledTimes(1); expect(refreshZoomProvider).toHaveBeenCalledTimes(1); expect(events).toEqual([ 'start:replace', 'clear-active', 'clear-doc', 'complete:replace:true', 'start:replace', 'clear-active', 'clear-doc', 'complete:replace:true', ]); const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const error = new Error('facade dispose failed'); try { actions.destroyRenderSession({ destroy: () => { throw error; }, }); expect(warn).toHaveBeenCalledWith('预览内容卸载失败', error); } finally { warn.mockRestore(); } }); it('removes stale render targets and disposes stale sessions during surface mount', async () => { const { document } = parseHTML('
'); const root = document.getElementById('root') as HTMLElement; const session = { destroy: vi.fn() }; const state = createFileViewerRenderSurfaceState(); const readiness = { renderedReady: false, progressiveReady: false, }; let currentVersion = 1; const render = vi.fn(async context => { context.target.appendChild(document.createElement('strong')); currentVersion = 2; return session; }); await expect(runFileViewerRenderSurfaceMount({ buffer: new ArrayBuffer(4), file: new File(['demo'], 'demo.pdf'), version: 1, getContainer: () => root, surfaceState: state, readinessState: readiness, isCurrent: version => version === currentVersion, clearRenderedContent: () => clearFileViewerRenderSurface(root), render, })).resolves.toBeUndefined(); expect(render).toHaveBeenCalledTimes(1); expect(session.destroy).toHaveBeenCalledTimes(1); expect(root.innerHTML).not.toContain('file-render'); }); it('safely disposes renderer sessions and reports teardown errors', async () => { const onError = vi.fn(); const syncError = new Error('sync teardown failed'); const asyncError = new Error('async teardown failed'); const syncDestroy = vi.fn(() => { throw syncError; }); const asyncDestroy = vi.fn(() => Promise.reject(asyncError)); disposeFileViewerRendererSession({ destroy: syncDestroy }, { onError }); disposeFileViewerRendererSession({ destroy: asyncDestroy }, { onError }); disposeFileViewerRendererSession(null, { onError }); await Promise.resolve(); expect(FILE_VIEWER_RENDER_SESSION_DISPOSE_ERROR_MESSAGE).toBe('预览内容卸载失败'); expect(resolveFileViewerRenderSessionDisposeErrorMessage()).toBe('预览内容卸载失败'); expect(resolveFileViewerRenderSessionDisposeErrorMessage({ message: '自定义卸载失败' })).toBe('自定义卸载失败'); const loggedDisposeWarnings: Array<[string, unknown]> = []; expect(reportFileViewerRenderSessionDisposeError({ error: syncError, onLogError: (message, error) => { loggedDisposeWarnings.push([message, error]); }, })).toBe('预览内容卸载失败'); expect(reportFileViewerRenderSessionDisposeError({ message: '自定义卸载失败', error: asyncError, onLogError: (message, error) => { loggedDisposeWarnings.push([message, error]); }, })).toBe('自定义卸载失败'); expect(loggedDisposeWarnings).toEqual([ ['预览内容卸载失败', syncError], ['自定义卸载失败', asyncError], ]); expect(syncDestroy).toHaveBeenCalledTimes(1); expect(asyncDestroy).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith(syncError); expect(onError).toHaveBeenCalledWith(asyncError); }); it('builds renderer registries from legacy render handlers', async () => { const unmount = vi.fn(); const handler: FileRenderHandler<{ unmount: () => void }, HTMLDivElement> = async (_buffer, target, type, context) => { target.dataset.renderedType = type || ''; target.dataset.filename = context?.filename || ''; return { unmount }; }; const target = { dataset: {} } as HTMLDivElement; const { registry, missingRendererIds } = createFileRenderHandlerRegistry({ definitions: [{ id: 'text-fixture', label: 'Text Fixture', category: 'code', extensions: ['txt-fixture'], }], handlers: [{ rendererId: 'text-fixture', handler, }], }); const renderer = registry.getByExtension('TXT-FIXTURE'); expect(missingRendererIds).toEqual([]); expect(renderer?.load).toBeTypeOf('function'); const session = await renderer?.load?.({ source: normalizeSource({ buffer: new ArrayBuffer(1), filename: 'demo.txt-fixture', }), surface: { container: target }, options: {}, }); expect(target.dataset.renderedType).toBe('txt-fixture'); expect(target.dataset.filename).toBe('demo.txt-fixture'); await session?.destroy?.(); expect(unmount).toHaveBeenCalledTimes(1); }); });