core-operations.spec.ts 48 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602
  1. import { describe, expect, it, vi } from 'vitest';
  2. import { parseHTML } from 'linkedom';
  3. import {
  4. FILE_VIEWER_BEFORE_OPERATION_ERROR_PREFIX,
  5. DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
  6. DEFAULT_FILE_VIEWER_EXPORT_FILENAME,
  7. DEFAULT_FILE_VIEWER_PREVIEW_TITLE,
  8. buildFileViewerLifecycleContext,
  9. buildFileViewerLifecycleContextFromNormalizedSource,
  10. buildFileViewerOperationContext,
  11. buildFileViewerOperationContextFromLifecycleState,
  12. cloneFileViewerOperationAvailability,
  13. createFileViewerLifecycleActions,
  14. createFileViewerLifecycleFacade,
  15. createFileViewerPublicApi,
  16. createFileViewerToolbarActions,
  17. createFileViewerToolbarControllerActionHandlers,
  18. createFileViewerToolbarZoomSyncSnapshot,
  19. createFileViewerOperationActionHandlers,
  20. createFileViewerLifecycleStateController,
  21. createFileViewerOriginalSourceState,
  22. createFileViewerOriginalSourceStateFromNormalizedSource,
  23. createFileViewerPublicOperationActionHandlers,
  24. dispatchFileViewerLifecycleEvent,
  25. dispatchFileViewerOperationContextEvent,
  26. dispatchFileViewerOperationAvailabilityChange,
  27. dispatchFileViewerZoomChange,
  28. emitFileViewerComponentLifecycleEvent,
  29. executeFileViewerDownloadOperation,
  30. executeFileViewerExportHtmlOperation,
  31. executeFileViewerPrintOperation,
  32. FILE_VIEWER_LIFECYCLE_HOOK_ERROR_MESSAGE_PREFIX,
  33. hasVisibleFileViewerToolbarActions,
  34. isFileViewerToolbarOperationPermitted,
  35. isFileViewerZoomButtonDisabled,
  36. normalizeFileViewerToolbar,
  37. reportFileViewerLifecycleHookError,
  38. reportFileViewerOperationError,
  39. resolveFileViewerBeforeOperationErrorMessage,
  40. resolveFileViewerLifecycleFallbackSource,
  41. resolveFileViewerLifecycleHookErrorMessage,
  42. resolveFileViewerOperationActionErrorMessage,
  43. resolveFileViewerOperationFilename,
  44. resolveFileViewerOriginalFilename,
  45. resolveFileViewerOperationAvailability,
  46. resolveFileViewerToolbarState,
  47. resolveFileViewerToolbarPosition,
  48. resolveFileViewerDisplayFilename,
  49. resolveVisibleFileViewerToolbar,
  50. runFileViewerActiveUnloadComplete,
  51. runFileViewerActiveUnloadStart,
  52. runFileViewerBeforeOperation,
  53. runFileViewerLifecycleHook,
  54. runFileViewerToolbarAvailabilitySync,
  55. runFileViewerToolbarZoomSync,
  56. serializeFileViewerContext,
  57. type FileViewerLifecycleComponentEmit,
  58. type FileViewerOperationType,
  59. } from '../packages/core/src';
  60. describe('@file-viewer/core operation helpers', () => {
  61. it('builds lifecycle and operation contexts without framework state', () => {
  62. const context = buildFileViewerLifecycleContext({
  63. phase: 'load-complete',
  64. source: 'url',
  65. url: '/docs/%E6%8A%A5%E5%91%8A.pdf?token=1',
  66. version: 7,
  67. startedAt: 1000,
  68. timestamp: 1240,
  69. bufferSize: 4096,
  70. });
  71. expect(context).toMatchObject({
  72. phase: 'load-complete',
  73. type: 'pdf',
  74. filename: '报告.pdf',
  75. source: 'url',
  76. url: '/docs/%E6%8A%A5%E5%91%8A.pdf?token=1',
  77. size: 4096,
  78. version: 7,
  79. timestamp: 1240,
  80. duration: 240,
  81. });
  82. expect(buildFileViewerLifecycleContext({
  83. phase: 'load-complete',
  84. source: 'url',
  85. url: '/docs/zero.pdf',
  86. version: 7,
  87. startedAt: 0,
  88. timestamp: 240,
  89. }).duration).toBe(240);
  90. expect(buildFileViewerOperationContext('print', context, 1300)).toMatchObject({
  91. operation: 'print',
  92. label: '打印完整渲染内容',
  93. filename: '报告.pdf',
  94. timestamp: 1300,
  95. });
  96. expect(buildFileViewerLifecycleContextFromNormalizedSource({
  97. phase: 'load-complete',
  98. source: {
  99. kind: 'url',
  100. filename: '计划.pdf',
  101. extension: 'pdf',
  102. url: '/docs/%E8%AE%A1%E5%88%92.pdf',
  103. size: 2048,
  104. },
  105. version: 8,
  106. startedAt: 0,
  107. timestamp: 320,
  108. })).toMatchObject({
  109. phase: 'load-complete',
  110. type: 'pdf',
  111. filename: '计划.pdf',
  112. source: 'url',
  113. url: '/docs/%E8%AE%A1%E5%88%92.pdf',
  114. size: 2048,
  115. version: 8,
  116. timestamp: 320,
  117. duration: 320,
  118. });
  119. });
  120. it('tracks lifecycle state in the framework-neutral core controller', () => {
  121. const controller = createFileViewerLifecycleStateController();
  122. const context = buildFileViewerLifecycleContext({
  123. phase: 'load-complete',
  124. source: 'file',
  125. filename: 'demo.pdf',
  126. version: 3,
  127. timestamp: 120,
  128. });
  129. controller.markLoadStarted(3, 100);
  130. expect(controller.getLoadStartedAt(3)).toBe(100);
  131. expect(controller.getActiveDocumentContext()).toBeNull();
  132. controller.setActiveDocumentContext(context);
  133. expect(controller.getActiveDocumentContext()).toBe(context);
  134. expect(controller.buildActiveUnloadContext('unload-start', context, 'replace', 130)).toMatchObject({
  135. phase: 'unload-start',
  136. filename: 'demo.pdf',
  137. reason: 'replace',
  138. timestamp: 130,
  139. });
  140. expect(controller.buildActiveUnloadContext('unload-complete', context, 'replace', 140)).toMatchObject({
  141. phase: 'unload-complete',
  142. filename: 'demo.pdf',
  143. reason: 'replace',
  144. timestamp: 140,
  145. });
  146. controller.clearLoadStarted(3);
  147. controller.clearActiveDocumentContext();
  148. expect(controller.getLoadStartedAt(3)).toBeUndefined();
  149. expect(controller.getActiveDocumentContext()).toBeNull();
  150. expect(controller.buildActiveUnloadContext('unload-start', null)).toBeNull();
  151. });
  152. it('runs active unload lifecycle orchestration without framework code', () => {
  153. const controller = createFileViewerLifecycleStateController();
  154. const onLifecycle = vi.fn();
  155. const context = buildFileViewerLifecycleContext({
  156. phase: 'load-complete',
  157. source: 'url',
  158. filename: 'active.docx',
  159. url: '/active.docx',
  160. version: 9,
  161. timestamp: 500,
  162. });
  163. expect(runFileViewerActiveUnloadStart({
  164. lifecycleState: controller,
  165. onLifecycle,
  166. })).toEqual({
  167. reason: 'replace',
  168. context: null,
  169. unloadContext: null,
  170. });
  171. expect(onLifecycle).not.toHaveBeenCalled();
  172. controller.setActiveDocumentContext(context);
  173. const started = runFileViewerActiveUnloadStart({
  174. lifecycleState: controller,
  175. reason: 'replace',
  176. onLifecycle,
  177. });
  178. expect(started.context).toBe(context);
  179. expect(started.unloadContext).toMatchObject({
  180. phase: 'unload-start',
  181. filename: 'active.docx',
  182. reason: 'replace',
  183. version: 9,
  184. });
  185. expect(onLifecycle).toHaveBeenCalledTimes(1);
  186. expect(onLifecycle).toHaveBeenLastCalledWith(started.unloadContext);
  187. const completed = runFileViewerActiveUnloadComplete({
  188. lifecycleState: controller,
  189. context: started.context,
  190. reason: 'component-unmount',
  191. onLifecycle,
  192. });
  193. expect(completed.context).toBe(context);
  194. expect(completed.unloadContext).toMatchObject({
  195. phase: 'unload-complete',
  196. filename: 'active.docx',
  197. reason: 'component-unmount',
  198. version: 9,
  199. });
  200. expect(onLifecycle).toHaveBeenCalledTimes(2);
  201. expect(onLifecycle).toHaveBeenLastCalledWith(completed.unloadContext);
  202. });
  203. it('dispatches component lifecycle emits and formats beforeOperation errors through core', () => {
  204. const emitted: string[] = [];
  205. const emit = ((event: string, context: { filename: string }) => {
  206. emitted.push(`${event}:${context.filename}`);
  207. }) as FileViewerLifecycleComponentEmit;
  208. const context = buildFileViewerLifecycleContext({
  209. phase: 'load-start',
  210. source: 'url',
  211. filename: 'contract.pdf',
  212. version: 11,
  213. });
  214. emitFileViewerComponentLifecycleEvent(emit, context);
  215. expect(emitted).toEqual(['load-start:contract.pdf']);
  216. expect(resolveFileViewerBeforeOperationErrorMessage({
  217. error: new Error('denied'),
  218. formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
  219. })).toBe(`${FILE_VIEWER_BEFORE_OPERATION_ERROR_PREFIX}:denied`);
  220. expect(resolveFileViewerBeforeOperationErrorMessage({
  221. error: 'offline',
  222. prefix: '权限校验失败',
  223. formatErrorMessage: (prefix, error) => `${prefix}:${String(error)}`,
  224. })).toBe('权限校验失败:offline');
  225. const hookError = new Error('hook failed');
  226. const lifecycleLogs: Array<[string, unknown, string]> = [];
  227. expect(FILE_VIEWER_LIFECYCLE_HOOK_ERROR_MESSAGE_PREFIX).toBe('FileViewer');
  228. expect(resolveFileViewerLifecycleHookErrorMessage({ context })).toBe('FileViewer load-start hook failed');
  229. expect(resolveFileViewerLifecycleHookErrorMessage({
  230. context,
  231. prefix: 'Viewer',
  232. })).toBe('Viewer load-start hook failed');
  233. expect(reportFileViewerLifecycleHookError({
  234. error: hookError,
  235. context,
  236. onLogError: (message, error, nextContext) => {
  237. lifecycleLogs.push([message, error, nextContext.phase]);
  238. },
  239. })).toBe('FileViewer load-start hook failed');
  240. expect(lifecycleLogs).toEqual([['FileViewer load-start hook failed', hookError, 'load-start']]);
  241. const operationContext = buildFileViewerOperationContext('download', context, 220);
  242. const operationLogs: Array<[unknown, string]> = [];
  243. expect(reportFileViewerOperationError({
  244. error: hookError,
  245. context: operationContext,
  246. onLogError: (error, nextContext) => {
  247. operationLogs.push([error, nextContext.operation]);
  248. },
  249. })).toBe(hookError);
  250. expect(operationLogs).toEqual([[hookError, 'download']]);
  251. });
  252. it('builds operation contexts from lifecycle state without wrapper fallback logic', () => {
  253. const controller = createFileViewerLifecycleStateController();
  254. const activeContext = buildFileViewerLifecycleContext({
  255. phase: 'load-complete',
  256. source: 'file',
  257. filename: 'active.pdf',
  258. version: 5,
  259. timestamp: 320,
  260. });
  261. controller.setActiveDocumentContext(activeContext);
  262. expect(buildFileViewerOperationContextFromLifecycleState({
  263. operation: 'download',
  264. lifecycleState: controller,
  265. version: 5,
  266. fallbackUrl: '/ignored.docx',
  267. timestamp: 360,
  268. })).toMatchObject({
  269. operation: 'download',
  270. source: 'file',
  271. filename: 'active.pdf',
  272. version: 5,
  273. timestamp: 360,
  274. });
  275. controller.clearActiveDocumentContext();
  276. controller.markLoadStarted(8, 100);
  277. expect(buildFileViewerOperationContextFromLifecycleState({
  278. operation: 'print',
  279. lifecycleState: controller,
  280. version: 8,
  281. filename: 'fallback.docx',
  282. bufferSize: 512,
  283. fallbackUrl: '/docs/fallback.docx',
  284. lifecycleTimestamp: 150,
  285. timestamp: 170,
  286. })).toMatchObject({
  287. operation: 'print',
  288. label: '打印完整渲染内容',
  289. type: 'docx',
  290. filename: 'fallback.docx',
  291. source: 'url',
  292. url: '/docs/fallback.docx',
  293. size: 512,
  294. version: 8,
  295. timestamp: 170,
  296. duration: 50,
  297. });
  298. });
  299. it('resolves lifecycle fallback sources without wrapper-specific branching', () => {
  300. expect(resolveFileViewerLifecycleFallbackSource({
  301. file: new File(['demo'], 'demo.txt'),
  302. url: '/ignored.txt',
  303. })).toEqual({ source: 'file' });
  304. expect(resolveFileViewerLifecycleFallbackSource({
  305. url: '/example/demo.pdf',
  306. })).toEqual({ source: 'url', sourceUrl: '/example/demo.pdf' });
  307. expect(resolveFileViewerLifecycleFallbackSource()).toEqual({ source: 'empty' });
  308. });
  309. it('serializes contexts without leaking File objects', () => {
  310. const file = new File(['demo'], 'demo.docx');
  311. const context = buildFileViewerLifecycleContext({
  312. phase: 'load-start',
  313. source: 'file',
  314. file,
  315. version: 1,
  316. timestamp: 10,
  317. });
  318. expect(serializeFileViewerContext(context)).toEqual({
  319. phase: 'load-start',
  320. type: 'docx',
  321. filename: 'demo.docx',
  322. source: 'file',
  323. url: undefined,
  324. size: 4,
  325. version: 1,
  326. timestamp: 10,
  327. duration: undefined,
  328. reason: undefined,
  329. hasFile: true,
  330. });
  331. });
  332. it('dispatches lifecycle and operation context events in wrapper event order', () => {
  333. const events: string[] = [];
  334. const lifecycleContext = buildFileViewerLifecycleContext({
  335. phase: 'load-complete',
  336. source: 'url',
  337. filename: 'dispatch.pdf',
  338. url: '/docs/dispatch.pdf',
  339. version: 3,
  340. timestamp: 300,
  341. });
  342. const operationContext = buildFileViewerOperationContext('print', lifecycleContext, 320);
  343. expect(dispatchFileViewerLifecycleEvent({
  344. context: lifecycleContext,
  345. hooks: {
  346. onLoadComplete: context => {
  347. events.push(`hook:${context.phase}`);
  348. },
  349. },
  350. onChange: (event, context) => {
  351. events.push(`emit:${event}:${context.filename}`);
  352. },
  353. })).toBe(true);
  354. expect(dispatchFileViewerOperationContextEvent({
  355. event: 'operation-before',
  356. context: operationContext,
  357. onChange: context => {
  358. events.push(`emit:${context.operation}`);
  359. },
  360. })).toBe(true);
  361. expect(events).toEqual([
  362. 'emit:load-complete:dispatch.pdf',
  363. 'hook:load-complete',
  364. 'emit:print',
  365. ]);
  366. });
  367. it('creates lifecycle actions that reuse dispatch, unload and operation guards', async () => {
  368. const events: string[] = [];
  369. const lifecycleState = createFileViewerLifecycleStateController();
  370. const context = buildFileViewerLifecycleContext({
  371. phase: 'load-complete',
  372. source: 'file',
  373. filename: 'actions.pdf',
  374. version: 8,
  375. timestamp: 400,
  376. });
  377. lifecycleState.setActiveDocumentContext(context);
  378. const actions = createFileViewerLifecycleActions({
  379. lifecycleState,
  380. getOptions: () => ({
  381. hooks: {
  382. onUnloadStart: nextContext => {
  383. events.push(`hook:${nextContext.phase}`);
  384. },
  385. },
  386. beforeOperation: nextContext => {
  387. events.push(`guard:${nextContext.operation}`);
  388. return false;
  389. },
  390. }),
  391. onLifecycleChange: (event, nextContext) => {
  392. events.push(`emit:${event}:${nextContext.filename}`);
  393. },
  394. onOperationBefore: nextContext => {
  395. events.push(`before:${nextContext.operation}`);
  396. },
  397. onOperationCancel: nextContext => {
  398. events.push(`cancel:${nextContext.operation}`);
  399. },
  400. });
  401. const unloadStartContext = actions.notifyActiveUnloadStart('replace');
  402. expect(unloadStartContext).toMatchObject({
  403. phase: 'load-complete',
  404. reason: undefined,
  405. filename: 'actions.pdf',
  406. });
  407. actions.notifyActiveUnloadComplete(unloadStartContext, 'replace');
  408. await expect(actions.runBeforeOperation(
  409. buildFileViewerOperationContext('download', context, 430)
  410. )).resolves.toBe(false);
  411. expect(events).toEqual([
  412. 'emit:unload-start:actions.pdf',
  413. 'hook:unload-start',
  414. 'emit:unload-complete:actions.pdf',
  415. 'before:download',
  416. 'guard:download',
  417. 'cancel:download',
  418. ]);
  419. });
  420. it('creates lifecycle facades from wrapper state getters', async () => {
  421. const events: string[] = [];
  422. const errors: string[] = [];
  423. const lifecycle = createFileViewerLifecycleFacade({
  424. getOptions: () => ({
  425. beforeOperation: context => {
  426. events.push(`guard:${context.operation}:${context.filename}`);
  427. return false;
  428. },
  429. }),
  430. getFilename: () => 'facade.pdf',
  431. getBufferSize: () => 2048,
  432. getCurrentFile: () => null,
  433. getCurrentVersion: () => 12,
  434. getFallbackFile: () => undefined,
  435. getFallbackUrl: () => '/fallback.pdf',
  436. emitLifecycle: (event, context) => {
  437. events.push(`emit:${event}:${context.filename}`);
  438. },
  439. emitOperationBefore: context => {
  440. events.push(`before:${context.operation}:${context.source}`);
  441. },
  442. emitOperationCancel: context => {
  443. events.push(`cancel:${context.operation}`);
  444. },
  445. formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
  446. handleLifecycleError: error => {
  447. errors.push(String(error));
  448. },
  449. onOperationErrorMessage: message => {
  450. errors.push(message);
  451. },
  452. });
  453. lifecycle.markLoadStarted(12, 100);
  454. const loadStart = lifecycle.buildLoadStartState({
  455. version: 12,
  456. source: 'url',
  457. sourceUrl: '/facade.pdf',
  458. });
  459. expect(loadStart.lifecycleContext).toMatchObject({
  460. phase: 'load-start',
  461. source: 'url',
  462. filename: 'facade.pdf',
  463. size: 2048,
  464. url: '/facade.pdf',
  465. });
  466. lifecycle.notifyLifecycle(loadStart.lifecycleContext);
  467. const complete = lifecycle.buildRenderCompleteState({
  468. version: 12,
  469. source: 'url',
  470. sourceUrl: '/facade.pdf',
  471. });
  472. expect(complete.lifecycleContext).toMatchObject({
  473. phase: 'load-complete',
  474. filename: 'facade.pdf',
  475. });
  476. lifecycle.setActiveDocumentContext(complete.lifecycleContext);
  477. expect(lifecycle.buildOperationContext('download')).toMatchObject({
  478. operation: 'download',
  479. label: '下载原始文件',
  480. filename: 'facade.pdf',
  481. source: 'url',
  482. size: 2048,
  483. });
  484. await expect(lifecycle.runBeforeOperation('download')).resolves.toBe(false);
  485. lifecycle.clearLoadStarted(12);
  486. lifecycle.clearActiveDocumentContext();
  487. expect(lifecycle.buildOperationContext('print')).toMatchObject({
  488. operation: 'print',
  489. source: 'url',
  490. url: '/fallback.pdf',
  491. filename: 'facade.pdf',
  492. });
  493. expect(events).toEqual([
  494. 'emit:load-start:facade.pdf',
  495. 'before:download:url',
  496. 'guard:download:facade.pdf',
  497. 'cancel:download',
  498. ]);
  499. expect(errors).toEqual([]);
  500. });
  501. it('dispatches toolbar change notifications in wrapper event order', () => {
  502. const events: string[] = [];
  503. const availability = {
  504. download: true,
  505. print: false,
  506. exportHtml: true,
  507. zoom: true,
  508. zoomIn: true,
  509. zoomOut: false,
  510. zoomReset: false,
  511. };
  512. const zoomState = {
  513. scale: 1.5,
  514. label: '150%',
  515. canZoomIn: true,
  516. canZoomOut: true,
  517. canReset: true,
  518. };
  519. let emittedAvailability: typeof availability | null = null;
  520. let emittedZoomState: typeof zoomState | null = null;
  521. expect(dispatchFileViewerOperationAvailabilityChange({
  522. availability,
  523. onChange: payload => {
  524. events.push('emit:operation-availability-change');
  525. emittedAvailability = payload;
  526. payload.download = false;
  527. },
  528. })).toBe(true);
  529. expect(dispatchFileViewerZoomChange({
  530. state: zoomState,
  531. onChange: payload => {
  532. events.push('emit:zoom-change');
  533. emittedZoomState = payload;
  534. },
  535. })).toBe(true);
  536. expect(events).toEqual([
  537. 'emit:operation-availability-change',
  538. 'emit:zoom-change',
  539. ]);
  540. expect(emittedAvailability).toEqual({
  541. ...availability,
  542. download: false,
  543. });
  544. expect(emittedAvailability).not.toBe(availability);
  545. expect(availability.download).toBe(true);
  546. expect(emittedZoomState).toBe(zoomState);
  547. });
  548. it('creates toolbar actions that reuse notification dispatch and zoom guard rules', () => {
  549. const events: string[] = [];
  550. const availability = {
  551. download: true,
  552. print: false,
  553. exportHtml: true,
  554. zoom: true,
  555. zoomIn: true,
  556. zoomOut: false,
  557. zoomReset: true,
  558. };
  559. let toolbarDisabled = false;
  560. const zoomState = {
  561. scale: 1,
  562. label: '100%',
  563. canZoomIn: true,
  564. canZoomOut: false,
  565. canReset: true,
  566. };
  567. const actions = createFileViewerToolbarActions({
  568. getOperationAvailability: () => availability,
  569. getToolbarDisabled: () => toolbarDisabled,
  570. getZoomState: () => zoomState,
  571. onOperationAvailabilityChange: payload => {
  572. events.push(`emit:availability:${payload.zoomOut}`);
  573. },
  574. onZoomChange: payload => {
  575. events.push(`emit:zoom:${payload.label}`);
  576. },
  577. });
  578. expect(actions.isZoomButtonDisabled('canZoomIn')).toBe(false);
  579. expect(actions.isZoomButtonDisabled('canZoomOut')).toBe(true);
  580. toolbarDisabled = true;
  581. expect(actions.isZoomButtonDisabled('canZoomIn')).toBe(true);
  582. expect(actions.notifyOperationAvailabilityChange()).toBe(true);
  583. expect(actions.notifyZoomChange()).toBe(true);
  584. expect(events).toEqual([
  585. 'emit:availability:false',
  586. 'emit:zoom:100%',
  587. ]);
  588. });
  589. it('runs toolbar sync notifications and keeps zoom sync snapshot fields stable', () => {
  590. const events: string[] = [];
  591. const availability = {
  592. download: true,
  593. print: false,
  594. exportHtml: true,
  595. zoom: true,
  596. zoomIn: true,
  597. zoomOut: true,
  598. zoomReset: false,
  599. };
  600. const zoomState = {
  601. scale: 1.25,
  602. label: '125%',
  603. canZoomIn: true,
  604. canZoomOut: true,
  605. canReset: true,
  606. minScale: 0.25,
  607. maxScale: 4,
  608. };
  609. const toolbarActions = {
  610. notifyOperationAvailabilityChange(nextAvailability = availability) {
  611. events.push(`availability:${nextAvailability.zoomReset}`);
  612. return true;
  613. },
  614. notifyZoomChange(nextState = zoomState) {
  615. events.push(`zoom:${nextState.label}`);
  616. return true;
  617. },
  618. };
  619. expect(createFileViewerToolbarZoomSyncSnapshot(zoomState)).toEqual([
  620. 1.25,
  621. '125%',
  622. true,
  623. true,
  624. true,
  625. ]);
  626. expect(runFileViewerToolbarAvailabilitySync({
  627. toolbarActions,
  628. availability,
  629. })).toBe(true);
  630. expect(runFileViewerToolbarZoomSync({
  631. toolbarActions,
  632. state: zoomState,
  633. })).toBe(true);
  634. expect(events).toEqual([
  635. 'availability:false',
  636. 'zoom:125%',
  637. ]);
  638. });
  639. it('creates reusable toolbar controller action handlers for framework wrappers', () => {
  640. const events: string[] = [];
  641. const zoomState = {
  642. scale: 1,
  643. label: '100%',
  644. canZoomIn: true,
  645. canZoomOut: false,
  646. canReset: true,
  647. minScale: 0.25,
  648. maxScale: 4,
  649. };
  650. let buffer: ArrayBuffer | null = new ArrayBuffer(8);
  651. let hasError = false;
  652. let loading = false;
  653. const controller = createFileViewerToolbarControllerActionHandlers({
  654. getAdapter: () => ({ exportHtml: true }),
  655. getBuffer: () => buffer,
  656. getExtension: () => 'pdf',
  657. getFile: () => null,
  658. getHasError: () => hasError,
  659. getLoading: () => loading,
  660. getOptions: () => undefined,
  661. getSourceUrl: () => null,
  662. getToolbar: () => ({
  663. download: true,
  664. print: true,
  665. exportHtml: true,
  666. zoom: true,
  667. }),
  668. getRenderedReady: () => true,
  669. getZoomState: () => zoomState,
  670. zoomSyncState: zoomState,
  671. onOperationAvailabilityChange: availability => {
  672. events.push(`emit:availability:${availability.download}:${availability.zoomOut}`);
  673. },
  674. onZoomChange: state => {
  675. events.push(`emit:zoom:${state.label}`);
  676. },
  677. });
  678. const state = controller.resolveToolbarState();
  679. expect(state.operationAvailability).toMatchObject({
  680. download: true,
  681. print: false,
  682. exportHtml: true,
  683. zoom: true,
  684. zoomIn: true,
  685. zoomOut: false,
  686. zoomReset: true,
  687. });
  688. expect(state.visibleToolbar).toEqual({
  689. download: true,
  690. print: false,
  691. exportHtml: true,
  692. zoom: true,
  693. });
  694. expect(state.toolbarPosition).toBe('bottom-right');
  695. expect(state.toolbarDisabled).toBe(false);
  696. expect(controller.createZoomSyncSnapshot()).toEqual([
  697. 1,
  698. '100%',
  699. true,
  700. false,
  701. true,
  702. ]);
  703. expect(controller.isZoomButtonDisabled('canZoomIn')).toBe(false);
  704. expect(controller.isZoomButtonDisabled('canZoomOut')).toBe(true);
  705. expect(controller.syncOperationAvailability()).toBe(true);
  706. expect(controller.syncZoomChange()).toBe(true);
  707. expect(events).toEqual([
  708. 'emit:availability:true:false',
  709. 'emit:zoom:100%',
  710. ]);
  711. buffer = null;
  712. hasError = true;
  713. loading = true;
  714. const disabledState = controller.resolveToolbarState();
  715. expect(disabledState.operationAvailability.download).toBe(false);
  716. expect(disabledState.operationAvailability.exportHtml).toBe(false);
  717. expect(disabledState.toolbarDisabled).toBe(true);
  718. expect(controller.isZoomButtonDisabled('canZoomIn')).toBe(true);
  719. });
  720. it('runs lifecycle hooks and operation guards in deterministic order', async () => {
  721. const events: string[] = [];
  722. const context = buildFileViewerLifecycleContext({
  723. phase: 'load-start',
  724. source: 'file',
  725. filename: 'guard.pdf',
  726. version: 1,
  727. timestamp: 10,
  728. });
  729. const operationContext = buildFileViewerOperationContext('download', context, 12);
  730. await runFileViewerLifecycleHook(context, {
  731. onLoadStart: nextContext => events.push(`lifecycle:${nextContext.phase}`),
  732. });
  733. const allowed = await runFileViewerBeforeOperation({
  734. context: operationContext,
  735. options: {
  736. beforeOperation: nextContext => {
  737. events.push(`global:${nextContext.operation}`);
  738. },
  739. toolbar: {
  740. beforeOperation: nextContext => {
  741. events.push(`toolbar:${nextContext.operation}`);
  742. },
  743. beforeDownload: () => {
  744. events.push('download:false');
  745. return false;
  746. },
  747. },
  748. },
  749. onBefore: nextContext => events.push(`before:${nextContext.operation}`),
  750. onCancel: nextContext => events.push(`cancel:${nextContext.operation}`),
  751. });
  752. expect(allowed).toBe(false);
  753. expect(events).toEqual([
  754. 'lifecycle:load-start',
  755. 'before:download',
  756. 'global:download',
  757. 'toolbar:download',
  758. 'download:false',
  759. 'cancel:download',
  760. ]);
  761. });
  762. it('blocks public operations through toolbar permission maps before custom hooks run', async () => {
  763. const events: string[] = [];
  764. const context = buildFileViewerLifecycleContext({
  765. phase: 'load-complete',
  766. source: 'url',
  767. url: '/secure.pdf',
  768. version: 1,
  769. timestamp: 10,
  770. });
  771. const operationContext = buildFileViewerOperationContext('print', context, 12);
  772. const allowed = await runFileViewerBeforeOperation({
  773. context: operationContext,
  774. options: {
  775. beforeOperation: nextContext => {
  776. events.push(`global:${nextContext.operation}`);
  777. },
  778. toolbar: {
  779. permissions: {
  780. print: false,
  781. },
  782. },
  783. },
  784. onBefore: nextContext => events.push(`before:${nextContext.operation}`),
  785. onCancel: nextContext => events.push(`cancel:${nextContext.operation}`),
  786. });
  787. expect(allowed).toBe(false);
  788. expect(events).toEqual([
  789. 'before:print',
  790. 'cancel:print',
  791. ]);
  792. });
  793. it('normalizes toolbar visibility and operation availability in core', () => {
  794. expect(normalizeFileViewerToolbar(undefined)).toEqual({
  795. download: true,
  796. print: true,
  797. exportHtml: true,
  798. zoom: true,
  799. });
  800. expect(normalizeFileViewerToolbar({ toolbar: false })).toEqual({
  801. download: false,
  802. print: false,
  803. exportHtml: false,
  804. zoom: false,
  805. });
  806. expect(normalizeFileViewerToolbar({
  807. toolbar: {
  808. download: true,
  809. print: true,
  810. exportHtml: true,
  811. zoom: true,
  812. items: {
  813. print: false,
  814. 'zoom-out': false,
  815. },
  816. permissions: {
  817. 'export-html': false,
  818. 'zoom-reset': false,
  819. },
  820. position: 'bottom-right',
  821. },
  822. })).toMatchObject({
  823. download: true,
  824. print: false,
  825. exportHtml: false,
  826. zoom: true,
  827. items: {
  828. print: false,
  829. 'zoom-out': false,
  830. },
  831. permissions: {
  832. 'export-html': false,
  833. 'zoom-reset': false,
  834. },
  835. position: 'bottom-right',
  836. });
  837. const availability = resolveFileViewerOperationAvailability({
  838. extension: 'pdf',
  839. hasOriginalSource: true,
  840. renderedReady: true,
  841. adapter: { toHtml: () => '<main>pdf</main>' },
  842. zoomState: {
  843. scale: 1,
  844. label: '100%',
  845. canZoomIn: true,
  846. canZoomOut: false,
  847. canReset: false,
  848. },
  849. });
  850. expect(availability).toMatchObject({
  851. download: true,
  852. print: true,
  853. exportHtml: true,
  854. zoom: true,
  855. zoomIn: true,
  856. zoomOut: false,
  857. zoomReset: false,
  858. });
  859. expect(resolveVisibleFileViewerToolbar({ download: true, print: false, exportHtml: true, zoom: true }, availability)).toEqual({
  860. download: true,
  861. print: false,
  862. exportHtml: true,
  863. zoom: true,
  864. });
  865. expect(hasVisibleFileViewerToolbarActions({
  866. download: false,
  867. print: false,
  868. exportHtml: false,
  869. zoom: false,
  870. })).toBe(false);
  871. expect(hasVisibleFileViewerToolbarActions({
  872. download: false,
  873. print: false,
  874. exportHtml: true,
  875. zoom: false,
  876. })).toBe(true);
  877. expect(isFileViewerZoomButtonDisabled({
  878. action: 'canZoomIn',
  879. availability,
  880. zoomState: {
  881. scale: 1,
  882. label: '100%',
  883. canZoomIn: true,
  884. canZoomOut: false,
  885. canReset: false,
  886. },
  887. })).toBe(false);
  888. expect(isFileViewerZoomButtonDisabled({
  889. action: 'canZoomOut',
  890. availability,
  891. zoomState: {
  892. scale: 1,
  893. label: '100%',
  894. canZoomIn: true,
  895. canZoomOut: false,
  896. canReset: false,
  897. },
  898. })).toBe(true);
  899. expect(isFileViewerZoomButtonDisabled({
  900. action: 'canZoomIn',
  901. availability,
  902. toolbarDisabled: true,
  903. zoomState: {
  904. scale: 1,
  905. label: '100%',
  906. canZoomIn: true,
  907. canZoomOut: false,
  908. canReset: false,
  909. },
  910. })).toBe(true);
  911. expect(resolveFileViewerToolbarState({
  912. extension: 'pdf',
  913. hasOriginalSource: true,
  914. renderedReady: true,
  915. adapter: { toHtml: () => '<main>pdf</main>' },
  916. zoomState: {
  917. scale: 1,
  918. label: '100%',
  919. canZoomIn: true,
  920. canZoomOut: false,
  921. canReset: false,
  922. },
  923. toolbar: {
  924. download: true,
  925. print: false,
  926. exportHtml: true,
  927. zoom: true,
  928. },
  929. loading: true,
  930. })).toMatchObject({
  931. operationAvailability: {
  932. download: true,
  933. print: true,
  934. exportHtml: true,
  935. zoom: true,
  936. },
  937. visibleToolbar: {
  938. download: true,
  939. print: false,
  940. exportHtml: true,
  941. zoom: true,
  942. },
  943. showToolbar: true,
  944. toolbarPosition: 'bottom-right',
  945. toolbarDisabled: true,
  946. });
  947. const permissionState = resolveFileViewerToolbarState({
  948. extension: 'pdf',
  949. hasOriginalSource: true,
  950. renderedReady: true,
  951. adapter: { toHtml: () => '<main>pdf</main>' },
  952. zoomState: {
  953. scale: 1,
  954. label: '100%',
  955. canZoomIn: true,
  956. canZoomOut: true,
  957. canReset: true,
  958. },
  959. toolbar: normalizeFileViewerToolbar({
  960. toolbar: {
  961. download: true,
  962. print: true,
  963. exportHtml: true,
  964. zoom: true,
  965. permissions: {
  966. download: false,
  967. 'zoom-in': false,
  968. },
  969. },
  970. }),
  971. options: {
  972. toolbar: {
  973. permissions: {
  974. download: false,
  975. 'zoom-in': false,
  976. },
  977. },
  978. },
  979. });
  980. expect(permissionState.operationAvailability).toMatchObject({
  981. download: false,
  982. print: true,
  983. exportHtml: true,
  984. zoom: true,
  985. zoomIn: false,
  986. zoomOut: true,
  987. zoomReset: true,
  988. });
  989. expect(permissionState.visibleToolbar).toMatchObject({
  990. download: false,
  991. print: true,
  992. exportHtml: true,
  993. zoom: true,
  994. });
  995. expect(isFileViewerToolbarOperationPermitted({ permissions: { print: false } }, 'print')).toBe(false);
  996. expect(isFileViewerZoomButtonDisabled({
  997. action: 'canZoomIn',
  998. availability: permissionState.operationAvailability,
  999. zoomState: {
  1000. scale: 1,
  1001. label: '100%',
  1002. canZoomIn: true,
  1003. canZoomOut: true,
  1004. canReset: true,
  1005. },
  1006. })).toBe(true);
  1007. expect(resolveFileViewerOperationAvailability({
  1008. extension: 'docx',
  1009. source: {
  1010. file: new File(['demo'], 'demo.docx'),
  1011. },
  1012. renderedReady: false,
  1013. zoomState: {
  1014. scale: 1,
  1015. label: '100%',
  1016. canZoomIn: false,
  1017. canZoomOut: false,
  1018. canReset: false,
  1019. },
  1020. }).download).toBe(true);
  1021. expect(resolveFileViewerOperationAvailability({
  1022. extension: 'docx',
  1023. source: {},
  1024. renderedReady: false,
  1025. zoomState: {
  1026. scale: 1,
  1027. label: '100%',
  1028. canZoomIn: false,
  1029. canZoomOut: false,
  1030. canReset: false,
  1031. },
  1032. }).download).toBe(false);
  1033. expect(resolveFileViewerToolbarPosition(undefined, 'pdf')).toBe('bottom-right');
  1034. expect(resolveFileViewerToolbarPosition({ toolbar: { position: 'top' } }, 'pdf')).toBe('top');
  1035. });
  1036. it('resolves operation filenames from display names, original source metadata, and core defaults', () => {
  1037. expect(resolveFileViewerOperationFilename({
  1038. filename: '显示文档.pdf',
  1039. fallback: DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
  1040. })).toBe('显示文档.pdf');
  1041. expect(resolveFileViewerOperationFilename({
  1042. source: {
  1043. file: new File(['demo'], '原始文档.docx'),
  1044. },
  1045. fallback: DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
  1046. })).toBe('原始文档.docx');
  1047. expect(resolveFileViewerOperationFilename({
  1048. fallback: DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME,
  1049. })).toBe(DEFAULT_FILE_VIEWER_DOWNLOAD_FILENAME);
  1050. expect(resolveFileViewerOperationFilename({
  1051. fallback: DEFAULT_FILE_VIEWER_EXPORT_FILENAME,
  1052. })).toBe(DEFAULT_FILE_VIEWER_EXPORT_FILENAME);
  1053. expect(resolveFileViewerOperationFilename({})).toBe(DEFAULT_FILE_VIEWER_PREVIEW_TITLE);
  1054. expect(resolveFileViewerDisplayFilename({
  1055. kind: 'url',
  1056. filename: '线上报告.pdf',
  1057. extension: 'pdf',
  1058. url: '/docs/report.pdf',
  1059. })).toBe('线上报告.pdf');
  1060. expect(resolveFileViewerDisplayFilename(null)).toBe(DEFAULT_FILE_VIEWER_EXPORT_FILENAME);
  1061. expect(createFileViewerOriginalSourceStateFromNormalizedSource({
  1062. kind: 'url',
  1063. filename: '线上报告.pdf',
  1064. extension: 'pdf',
  1065. url: '/docs/report.pdf',
  1066. size: 1024,
  1067. })).toMatchObject({
  1068. buffer: null,
  1069. file: null,
  1070. url: '/docs/report.pdf',
  1071. filename: '线上报告.pdf',
  1072. });
  1073. });
  1074. it('clones operation availability snapshots without sharing mutable references', () => {
  1075. const availability = resolveFileViewerOperationAvailability({
  1076. extension: 'pdf',
  1077. hasOriginalSource: true,
  1078. renderedReady: true,
  1079. adapter: { toHtml: () => '<main>pdf</main>' },
  1080. zoomState: {
  1081. scale: 1.25,
  1082. label: '125%',
  1083. canZoomIn: false,
  1084. canZoomOut: true,
  1085. canReset: true,
  1086. },
  1087. });
  1088. const snapshot = cloneFileViewerOperationAvailability(availability);
  1089. expect(snapshot).toEqual(availability);
  1090. expect(snapshot).not.toBe(availability);
  1091. snapshot.download = false;
  1092. snapshot.zoomReset = false;
  1093. expect(availability.download).toBe(true);
  1094. expect(availability.zoomReset).toBe(true);
  1095. });
  1096. it('creates public api facades with cloned operation availability snapshots', async () => {
  1097. const availability = {
  1098. download: true,
  1099. print: true,
  1100. exportHtml: false,
  1101. zoom: true,
  1102. zoomIn: true,
  1103. zoomOut: false,
  1104. zoomReset: true,
  1105. };
  1106. const calls: string[] = [];
  1107. const api = createFileViewerPublicApi({
  1108. getOperationAvailability: () => availability,
  1109. downloadOriginalFile: async () => { calls.push('download'); },
  1110. printRenderedHtml: async () => { calls.push('print'); },
  1111. exportRenderedHtml: async () => { calls.push('export-html'); },
  1112. zoomIn: async () => ({
  1113. scale: 1.25,
  1114. label: '125%',
  1115. canZoomIn: true,
  1116. canZoomOut: true,
  1117. canReset: true,
  1118. }),
  1119. zoomOut: async () => ({
  1120. scale: 0.75,
  1121. label: '75%',
  1122. canZoomIn: true,
  1123. canZoomOut: true,
  1124. canReset: true,
  1125. }),
  1126. resetZoom: async () => ({
  1127. scale: 1,
  1128. label: '100%',
  1129. canZoomIn: true,
  1130. canZoomOut: false,
  1131. canReset: false,
  1132. }),
  1133. getZoomState: () => ({
  1134. scale: 1,
  1135. label: '100%',
  1136. canZoomIn: true,
  1137. canZoomOut: false,
  1138. canReset: false,
  1139. }),
  1140. getScrollContainer: () => null,
  1141. searchDocument: async query => ({
  1142. query,
  1143. total: 0,
  1144. currentIndex: -1,
  1145. current: null,
  1146. matches: [],
  1147. }),
  1148. clearDocumentSearch: async () => ({
  1149. query: '',
  1150. total: 0,
  1151. currentIndex: -1,
  1152. current: null,
  1153. matches: [],
  1154. }),
  1155. nextSearchResult: async () => ({
  1156. query: '',
  1157. total: 0,
  1158. currentIndex: -1,
  1159. current: null,
  1160. matches: [],
  1161. }),
  1162. previousSearchResult: async () => ({
  1163. query: '',
  1164. total: 0,
  1165. currentIndex: -1,
  1166. current: null,
  1167. matches: [],
  1168. }),
  1169. getSearchState: () => ({
  1170. query: '',
  1171. total: 0,
  1172. currentIndex: -1,
  1173. current: null,
  1174. matches: [],
  1175. }),
  1176. collectDocumentAnchors: async () => [],
  1177. scrollToAnchor: async () => true,
  1178. scrollToLine: async () => true,
  1179. getDocumentTextChunks: () => [],
  1180. });
  1181. const snapshot = api.getOperationAvailability();
  1182. snapshot.download = false;
  1183. expect(api.getOperationAvailability().download).toBe(true);
  1184. await api.downloadOriginalFile();
  1185. await api.printRenderedHtml();
  1186. await api.exportRenderedHtml();
  1187. expect(calls).toEqual(['download', 'print', 'export-html']);
  1188. });
  1189. it('reports operation guard errors and cancels the operation', async () => {
  1190. const onError = vi.fn();
  1191. const onCancel = vi.fn();
  1192. const context = buildFileViewerOperationContext('print', buildFileViewerLifecycleContext({
  1193. phase: 'load-complete',
  1194. source: 'file',
  1195. filename: 'broken.pdf',
  1196. version: 1,
  1197. timestamp: 10,
  1198. }));
  1199. await expect(runFileViewerBeforeOperation({
  1200. context,
  1201. options: {
  1202. beforeOperation: () => {
  1203. throw new Error('denied');
  1204. },
  1205. },
  1206. onError,
  1207. onCancel,
  1208. })).resolves.toBe(false);
  1209. expect(onError).toHaveBeenCalledTimes(1);
  1210. expect(onCancel).toHaveBeenCalledWith(context);
  1211. });
  1212. it('executes download, export and print through framework-neutral core operations', async () => {
  1213. const { document } = parseHTML('<main id="root"><article><h1>Preview</h1></article></main>');
  1214. const root = document.getElementById('root') as HTMLElement;
  1215. const originalDocument = globalThis.document;
  1216. const originalWindow = globalThis.window;
  1217. const originalCreateObjectUrl = URL.createObjectURL;
  1218. const originalRevokeObjectUrl = URL.revokeObjectURL;
  1219. const originalCreateElement = document.createElement.bind(document);
  1220. const beforeOperations: string[] = [];
  1221. let downloadedName = '';
  1222. let printed = false;
  1223. let printHtml = '';
  1224. document.createElement = ((tagName: string) => {
  1225. const element = originalCreateElement(tagName);
  1226. if (tagName.toLowerCase() === 'a') {
  1227. Object.defineProperty(element, 'click', {
  1228. configurable: true,
  1229. value: () => {
  1230. downloadedName = (element as HTMLAnchorElement).download;
  1231. },
  1232. });
  1233. }
  1234. return element;
  1235. }) as Document['createElement'];
  1236. Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
  1237. Object.defineProperty(globalThis, 'window', {
  1238. configurable: true,
  1239. value: {
  1240. requestAnimationFrame: (callback: () => void) => {
  1241. callback();
  1242. return 1;
  1243. },
  1244. setTimeout: (callback: () => void) => {
  1245. callback();
  1246. return 1;
  1247. },
  1248. },
  1249. });
  1250. URL.createObjectURL = () => 'blob:viewer-operation-test';
  1251. URL.revokeObjectURL = () => undefined;
  1252. const beforeOperation = (operation: FileViewerOperationType) => {
  1253. beforeOperations.push(operation);
  1254. return true;
  1255. };
  1256. const printWindow = {
  1257. document: {
  1258. readyState: 'complete',
  1259. images: [],
  1260. open: () => undefined,
  1261. write: (nextHtml: string) => {
  1262. printHtml = nextHtml;
  1263. },
  1264. close: () => undefined,
  1265. },
  1266. focus: () => undefined,
  1267. print: () => {
  1268. printed = true;
  1269. },
  1270. requestAnimationFrame: (callback: () => void) => {
  1271. callback();
  1272. return 1;
  1273. },
  1274. setTimeout: (callback: () => void) => {
  1275. callback();
  1276. return 1;
  1277. },
  1278. } as unknown as Window;
  1279. try {
  1280. const html = await executeFileViewerExportHtmlOperation({
  1281. source: root,
  1282. adapter: {
  1283. includeDocumentStyles: false,
  1284. toHtml: options => `<section data-mode="${options.mode}">${options.title}</section>`,
  1285. },
  1286. title: 'demo.viewer',
  1287. filename: 'demo.viewer',
  1288. download: false,
  1289. beforeOperation,
  1290. });
  1291. expect(html).toContain('data-mode="export"');
  1292. expect(html).toContain('demo.viewer');
  1293. await executeFileViewerPrintOperation({
  1294. source: root,
  1295. title: 'demo.viewer',
  1296. printWindow,
  1297. beforeOperation,
  1298. });
  1299. expect(printed).toBe(true);
  1300. expect(printHtml).toContain('<h1>Preview</h1>');
  1301. await executeFileViewerDownloadOperation({
  1302. source: {
  1303. buffer: new Uint8Array([1, 2, 3]).buffer,
  1304. filename: 'demo.bin',
  1305. },
  1306. beforeOperation,
  1307. });
  1308. expect(downloadedName).toBe('demo.bin');
  1309. expect(beforeOperations).toEqual(['export-html', 'print', 'download']);
  1310. await expect(executeFileViewerDownloadOperation({
  1311. source: {},
  1312. throwOnMissingSource: false,
  1313. })).resolves.toBe(false);
  1314. } finally {
  1315. document.createElement = originalCreateElement as Document['createElement'];
  1316. URL.createObjectURL = originalCreateObjectUrl;
  1317. URL.revokeObjectURL = originalRevokeObjectUrl;
  1318. Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
  1319. Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
  1320. }
  1321. });
  1322. it('creates framework-neutral file operation actions for wrappers', async () => {
  1323. const { document } = parseHTML('<main id="root"><article><h1>Action facade</h1></article></main>');
  1324. const root = document.getElementById('root') as HTMLElement;
  1325. const originalDocument = globalThis.document;
  1326. const originalWindow = globalThis.window;
  1327. const originalCreateObjectUrl = URL.createObjectURL;
  1328. const originalRevokeObjectUrl = URL.revokeObjectURL;
  1329. const originalCreateElement = document.createElement.bind(document);
  1330. const beforeOperations: string[] = [];
  1331. const errors: Array<{ operation: string; error: unknown }> = [];
  1332. const errorMessages: string[] = [];
  1333. let downloadedName = '';
  1334. let printHtml = '';
  1335. let printed = false;
  1336. let printAvailable = false;
  1337. document.createElement = ((tagName: string) => {
  1338. const element = originalCreateElement(tagName);
  1339. if (tagName.toLowerCase() === 'a') {
  1340. Object.defineProperty(element, 'click', {
  1341. configurable: true,
  1342. value: () => {
  1343. downloadedName = (element as HTMLAnchorElement).download;
  1344. },
  1345. });
  1346. }
  1347. return element;
  1348. }) as Document['createElement'];
  1349. const printWindow = {
  1350. document: {
  1351. readyState: 'complete',
  1352. images: [],
  1353. open: () => undefined,
  1354. write: (nextHtml: string) => {
  1355. printHtml = nextHtml;
  1356. },
  1357. close: () => undefined,
  1358. },
  1359. focus: () => undefined,
  1360. print: () => {
  1361. printed = true;
  1362. },
  1363. requestAnimationFrame: (callback: () => void) => {
  1364. callback();
  1365. return 1;
  1366. },
  1367. setTimeout: (callback: () => void) => {
  1368. callback();
  1369. return 1;
  1370. },
  1371. } as unknown as Window;
  1372. Object.defineProperty(globalThis, 'document', { configurable: true, value: document });
  1373. Object.defineProperty(globalThis, 'window', {
  1374. configurable: true,
  1375. value: {
  1376. open: () => printWindow,
  1377. requestAnimationFrame: (callback: () => void) => {
  1378. callback();
  1379. return 1;
  1380. },
  1381. setTimeout: (callback: () => void) => {
  1382. callback();
  1383. return 1;
  1384. },
  1385. },
  1386. });
  1387. URL.createObjectURL = () => 'blob:viewer-action-test';
  1388. URL.revokeObjectURL = () => undefined;
  1389. const actions = createFileViewerOperationActionHandlers({
  1390. getBuffer: () => new Uint8Array([4, 5, 6]).buffer,
  1391. getFile: () => null,
  1392. getUrl: () => null,
  1393. getFilename: () => 'suite.viewer',
  1394. getMimeType: () => 'application/octet-stream',
  1395. getRenderedSource: () => root,
  1396. getAdapter: () => ({
  1397. includeDocumentStyles: false,
  1398. toHtml: options => `<section data-mode="${options.mode}">${options.title}</section>`,
  1399. }),
  1400. getWatermarkInlineStyle: () => '--viewer-watermark: demo;',
  1401. getPrintAvailable: () => printAvailable,
  1402. beforeOperation: operation => {
  1403. beforeOperations.push(operation);
  1404. return true;
  1405. },
  1406. onError: context => {
  1407. errors.push(context);
  1408. },
  1409. formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
  1410. onErrorMessage: message => {
  1411. errorMessages.push(message);
  1412. },
  1413. });
  1414. try {
  1415. const html = await actions.exportRenderedHtml();
  1416. expect(html).toContain('data-mode="export"');
  1417. expect(downloadedName).toBe('suite.viewer.rendered.html');
  1418. await expect(actions.downloadOriginalFile()).resolves.toBe(true);
  1419. expect(downloadedName).toBe('suite.viewer');
  1420. await expect(actions.printRenderedHtml()).resolves.toBeUndefined();
  1421. expect(errors).toHaveLength(1);
  1422. expect(errors[0].operation).toBe('print');
  1423. expect(errorMessages[0]).toContain('打印失败:当前文件类型不支持完整打印');
  1424. expect(printed).toBe(false);
  1425. printAvailable = true;
  1426. await expect(actions.printRenderedHtml()).resolves.toBe(true);
  1427. expect(printed).toBe(true);
  1428. expect(printHtml).toContain('data-mode="print"');
  1429. expect(beforeOperations).toEqual(['export-html', 'download', 'print']);
  1430. } finally {
  1431. document.createElement = originalCreateElement as Document['createElement'];
  1432. URL.createObjectURL = originalCreateObjectUrl;
  1433. URL.revokeObjectURL = originalRevokeObjectUrl;
  1434. Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
  1435. Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
  1436. }
  1437. });
  1438. it('creates public operation action facades that preserve the void component contract', async () => {
  1439. const { document } = parseHTML('<main id="root"><article>Document</article></main>');
  1440. const root = document.querySelector('#root') as HTMLElement;
  1441. const beforeOperations: FileViewerOperationType[] = [];
  1442. const actions = createFileViewerPublicOperationActionHandlers({
  1443. getBuffer: () => null,
  1444. getFile: () => null,
  1445. getUrl: () => '/example/report.pdf',
  1446. getFilename: () => 'report.pdf',
  1447. getRenderedSource: () => root,
  1448. getPrintAvailable: () => true,
  1449. beforeOperation: operation => {
  1450. beforeOperations.push(operation);
  1451. return false;
  1452. },
  1453. });
  1454. await expect(actions.downloadOriginalFile()).resolves.toBeUndefined();
  1455. await expect(actions.exportRenderedHtml()).resolves.toBeUndefined();
  1456. await expect(actions.printRenderedHtml()).resolves.toBeUndefined();
  1457. expect(beforeOperations).toEqual(['download', 'export-html', 'print']);
  1458. });
  1459. it('formats operation action errors through core defaults', () => {
  1460. const message = resolveFileViewerOperationActionErrorMessage({
  1461. context: {
  1462. operation: 'export-html',
  1463. error: new Error('missing DOM'),
  1464. },
  1465. formatErrorMessage: (prefix, error) => `${prefix}:${error instanceof Error ? error.message : String(error)}`,
  1466. });
  1467. const customMessage = resolveFileViewerOperationActionErrorMessage({
  1468. context: {
  1469. operation: 'download',
  1470. error: 'offline',
  1471. },
  1472. prefixes: {
  1473. download: '保存失败',
  1474. },
  1475. formatErrorMessage: (prefix, error) => `${prefix}:${String(error)}`,
  1476. });
  1477. expect(message).toBe('导出 HTML 失败:missing DOM');
  1478. expect(customMessage).toBe('保存失败:offline');
  1479. });
  1480. it('normalizes original source state in core', () => {
  1481. const file = new File(['demo'], 'demo.docx', {
  1482. type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  1483. });
  1484. const source = createFileViewerOriginalSourceState({
  1485. file,
  1486. filename: 'display.docx',
  1487. });
  1488. expect(source).toEqual({
  1489. buffer: null,
  1490. file,
  1491. url: null,
  1492. filename: 'display.docx',
  1493. mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  1494. });
  1495. expect(resolveFileViewerOriginalFilename(source)).toBe('display.docx');
  1496. expect(createFileViewerOriginalSourceState({
  1497. buffer: new ArrayBuffer(2),
  1498. mimeType: 'application/octet-stream',
  1499. }).mimeType).toBe('application/octet-stream');
  1500. });
  1501. });