From ad9f77db04a1edeb4e7eb91a95c4e08a3f30a2e3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Mon, 21 Sep 2026 14:28:15 +0800 Subject: [PATCH] fix(preview): compact spreadsheet formula warning --- ...2026-09-16-browser-excel-preview.i18n.yaml | 4 +-- .../2026-09-16-browser-excel-preview.md | 2 +- .../2026-09-16-browser-excel-preview.zh.md | 2 +- apps/web/tests/document-preview.e2e.ts | 32 ++++++++++++++++--- .../README.i18n.yaml | 4 +-- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/excel/ExcelBody.module.css | 30 +++++++++++++---- .../src/client/excel/excel.tsx | 13 +++++--- .../src/client/excel/locales.ts | 12 ++----- .../tests/excel-body.client.spec.tsx | 20 ++++++------ .../tests/excel-convert.client.spec.ts | 15 +++++++-- .../tests/excel-fixture.ts | 28 ++++++++++++++++ .../web/document-preview/document.expected.md | 2 ++ 14 files changed, 122 insertions(+), 46 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml index 2c6432adcf..f279a70f08 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md -2026-09-16-browser-excel-preview.md: c31c563664d9c888a85d8b7fcb7f3abb50422f44 -2026-09-16-browser-excel-preview.zh.md: 09e24a28a76d00e60357b1c1acacdf82943fe567 +2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435 +2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8 diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md index c31c563664..5631906931 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md @@ -12,7 +12,7 @@ PDF conversion loses spreadsheet navigation and the relationship between a formu The [document preview](../../../../packages/client/ui-sidebar-documentpreview/README.md#excel-preview) opens XLSX, XLS, CSV, and TSV with FortuneSheet and package-local adapters for ExcelJS, SheetJS CE, and PapaParse. The adapter accepts bytes, format, and limits and returns worksheet data; it has no React state setters, timers, or component references. Dependencies own file parsing, while the adapters share display formatting and initial selection without introducing another workbook model. CSV/TSV retain literal strings and a plain-text option; implicit type inference could otherwise discard leading zeros or interpret source text as formulas. The shared document reader calls `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)` and receives native bytes through the binary Remote, retaining Session authorization, file versions, reload, and transient bytes without base64 decoding. Workspace Files owns bounded file reads; format parsing stays in the Client. The registration marks only XLSX/XLS as `binaryExtensions`, so the shared document container offers Plain text for CSV/TSV. Each renderer registers its body under its own id. -Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank with a workbook notice. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. +Preview is read-only and retains formula text alongside saved results. It does not recalculate, so unsupported functions cannot replace a valid saved result with an error. A formula without a saved result remains blank. Workbooks containing formulas show a compact warning beside the formula icon instead of consuming worksheet height with a notice row. The spreadsheet is neither an authoring tool nor a full Excel rendering engine. A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only. diff --git a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md index 09e24a28a7..4107b98624 100644 --- a/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-browser-excel-preview.zh.md @@ -12,7 +12,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。 [文档预览](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md#excel-preview)使用 FortuneSheet 与包内 ExcelJS、SheetJS CE 和 PapaParse 适配层打开 XLSX、XLS、CSV 和 TSV。适配层接收字节、格式和限制并返回工作表数据,不包含 React 状态设置器、计时器或组件引用。依赖库负责文件解析,适配层复用显示格式化和初始选区,不引入另一种工作簿模型。CSV/TSV 保留字面字符串和纯文本选项;隐式类型推断可能丢失前导零或把源文本解释为公式。共享文档读取器调用 `remote.workspaceFiles.readBytes(sessionId, path, {}, signal)`,并通过二进制 Remote 直接接收原生字节,保留 Session 授权、文件版本、重新载入和临时字节,无需 base64 解码。Workspace Files 负责有界文件读取,格式解析留在 Client。注册仅将 XLSX/XLS 标为 `binaryExtensions`,共享文档容器因此为 CSV/TSV 提供纯文本选项。每个渲染器以自己的 id 注册正文。 -预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空,并显示工作簿提示。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 +预览为只读,同时保留公式文本与已保存结果。它不重新计算,因此不受支持的函数不会用错误覆盖有效的已保存结果。没有已保存结果的公式保持为空。包含公式的工作簿会在公式图标旁显示紧凑提示,不再用整行提示占用工作表高度。该表格既不是编写工具,也不是完整的 Excel 渲染引擎。 独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index ec09035d09..ad27f80c82 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -9,7 +9,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' -import { excelFixture, excelHtmlFixture, excelHtmlText } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' +import { excelFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' @@ -246,6 +246,7 @@ else process.exit(1); ...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))), writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()), writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), + writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()), writeFile(join(cwd, 'literal-html.xlsx'), await excelHtmlFixture()), writeFile(join(cwd, 'budget.xls'), xlsFixture()), writeFile(join(cwd, 'table.csv'), '00123,"中文,字段","=SUM(1,2)"\n2024-03-01,,TRUE'), @@ -764,17 +765,37 @@ else process.exit(1); await successShot(page, 'excel-budget') expect(await preview.locator('[data-pdf-preview]').count()).toBe(0) expect(await excel.locator('.fortune-toolbar').count()).toBe(0) + const sheetOverlay = excel.locator('.fortune-sheet-overlay') + const formulaInput = excel.locator('.fortune-fx-input') + await sheetOverlay.click({ position: { x: 500, y: 110 } }) + await expect.poll(() => formulaInput.innerText()).toBe('=C3/B3') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('80.0%\n') await excel.getByText('公式与格式', { exact: true }).click() await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') - await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) - await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') - expect(await excel.locator('.fortune-fx-input').getAttribute('contenteditable')).toBe('false') + await sheetOverlay.click({ position: { x: 140, y: 30 } }) + await expect.poll(() => formulaInput.innerText()).toBe('46281') + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('2026-09-16\n') + await sheetOverlay.click({ position: { x: 70, y: 30 } }) + await expect.poll(() => formulaInput.innerText()).toBe('=_xlfn.XLOOKUP(1,{1},{42})') + expect(await formulaInput.getAttribute('contenteditable')).toBe('false') await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') await page.keyboard.type('999') await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n') await successShot(page, 'excel-cached-formula') + await openFile('meeting.xlsx') + await excel.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' }) + expect(await excel.getByText('Read-only preview', { exact: false }).count()).toBe(0) + expect(await excel.getByText('Some formulas have no saved result', { exact: false }).count()).toBe(0) + const formulaWarning = excel.locator('[data-excel-formula-warning]') + await formulaWarning.hover() + await page.getByText('This workbook contains formulas. Displayed results may be missing or inaccurate.', { exact: true }).waitFor() + await excel.getByText('统计看板', { exact: true }).click() + await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') + await successShot(page, 'excel-meeting') await openFile('budget.xls') await excel.getByText('预算', { exact: true }).waitFor({ state: 'visible' }) await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') @@ -786,7 +807,6 @@ else process.exit(1); await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1') await successShot(page, 'excel-legacy') await openFile('literal-html.xlsx') - const formulaInput = excel.locator('.fortune-fx-input') for (const [sheet, formula, copied] of [ ['Formula', `="${excelHtmlText}"`, 'saved result'], ['Text', excelHtmlText, excelHtmlText], @@ -812,6 +832,8 @@ else process.exit(1); '## Browser Excel preview', '', '- Opens without the Office conversion service', '- Sheets: 季度预算 | 公式与格式; hidden worksheet omitted', + '- Formula workbooks use a compact warning beside fx; notice rows absent', + '- Formatted percent copied: 80.0%; date copied: 2026-09-16', '- Cached XLOOKUP result copied: 42; typing leaves it unchanged', '- Formula bar is read-only; PDF body and editing toolbar absent', '- HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML', diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 767eda39c1..1fae2bc487 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md -README.md: 7e9650c3e65763dfe5536077e19adffd7cc70e02 -README.zh.md: d391c95f6b942596c5665022448189d6a70aa2b1 +README.md: 67136b8e5ff81dee26905be7734c981f18fc3b0a +README.zh.md: 9686863b9d265e7902f47948e5d73476bd023726 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 7e9650c3e6..67136b8e5f 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin ## Excel preview -Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results receive a notice. Spreadsheet preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. Spreadsheet preview does not call the Office conversion service. CSV and TSV default to the spreadsheet viewer and also offer Plain text. Commas and tabs delimit their fields respectively; quoted separators, escaped quotes, multiline fields, empty fields, and unequal row lengths are supported. The first row remains data. Values remain literal strings, including leading zeros, dates, booleans, and formula-looking text. Text files accept UTF-8 or BOM-marked UTF-16; invalid encoding receives conversion guidance. Malformed quoted fields fail the table preview rather than silently dropping data. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index d391c95f6b..9686863b9d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少结果时显示提示。表格预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。 CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css index 25d00bc7eb..efd00a9c2a 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -9,19 +9,35 @@ } .workbook { + position: relative; flex: 1; min-height: 0; min-width: 0; color-scheme: light; } -.notice { - flex: none; - margin: 0; - padding: 6px 12px; - font-size: 12px; - color: var(--dsw-alias-label-secondary); - border-bottom: 0.5px solid var(--dsw-alias-border-l3); +.formulaWarning { + position: absolute; + top: 4px; + left: 142px; + z-index: 3; + display: flex; + width: 20px; + height: 20px; + padding: 3px; + color: var(--dsw-alias-state-warn-label); + background: transparent; + border: 0; + border-radius: 4px; +} + +.formulaWarning:hover, .formulaWarning:focus-visible { + background: var(--dsw-alias-state-warn-tertiary); + outline: none; +} + +.withFormulaWarning :global(.fortune-fx-input) { + padding-left: 24px; } .status { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index 2aaa7ad200..b2e94fdea7 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -1,7 +1,7 @@ /** Read-only spreadsheet surface backed by browser-parsed workbook data. */ import { useEffect, useState, type ReactNode } from 'react' import { Workbook } from '@fortune-sheet/react' -import { Button, IconLoadingOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconLoadingOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import fortuneCss from '@fortune-sheet/react/dist/index.css?inline' import type { ExcelFormat } from './format.ts' import type { ExcelPreview } from './model.ts' @@ -19,7 +19,6 @@ const scopedStyles = `@scope ([data-excel-preview]) { ${fortuneCss} }` */ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): ReactNode { const data = content.kind === 'bytes' ? content.data : undefined - const delimited = format === 'csv' || format === 'tsv' const [state, setState] = useState() const [attempt, setAttempt] = useState(0) useEffect(() => { @@ -40,11 +39,15 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps): {t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')} + const hasFormulas = state.value.sheets.some(sheet => sheet.celldata?.some(cell => cell.v?.f !== undefined)) return
-

{t(delimited ? 'textSummary' : 'summary')}

- {state.value.missingResults > 0 &&

{t('missingResults')}

} -
+
+ {hasFormulas && + + } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts index cdcde81ed3..42c2b1f066 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/locales.ts @@ -4,11 +4,7 @@ export const zh = { invalid: '无法打开此表格。请检查文件格式、内容或密码保护。', tooLarge: '此表格超过预览大小限制。', timeout: '打开表格超时。请缩小文件后重试。', encoding: '无法识别此文本文件的编码。请另存为 UTF-8 或带 BOM 的 UTF-16 后重试。', - summary: '只读预览 · 显示已保存的公式结果', - textSummary: '只读预览 · 按原始文本显示单元格', - xlsLimitations: '旧版 XLS 显示数据、数字格式和基本布局;字体、边框、冻结窗格、图表和图片等暂不显示。', - limitations: '图表、图片、数据透视表和条件格式暂不显示。', - missingResults: '部分公式没有已保存的结果。请在 Excel 中重新计算并保存。', + formulaWarning: '此工作簿包含公式,显示结果可能缺失或不准确。', retry: '重试', } satisfies Record @@ -21,11 +17,7 @@ export const en = { invalid: 'This spreadsheet could not be opened. Check its format, contents, or password protection.', tooLarge: 'This workbook exceeds the preview size limit.', timeout: 'Opening this workbook timed out. Try a smaller file.', encoding: 'This text encoding could not be read. Save the file as UTF-8 or UTF-16 with a BOM and retry.', - summary: 'Read-only preview · Saved formula results', - textSummary: 'Read-only preview · Literal cell text', - xlsLimitations: 'Legacy XLS shows data, number formats, and basic layout; fonts, borders, frozen panes, charts, and images are not displayed.', - limitations: 'Charts, images, pivot tables, and conditional formatting are not displayed yet.', - missingResults: 'Some formulas have no saved result. Recalculate and save the file in Excel.', + formulaWarning: 'This workbook contains formulas. Displayed results may be missing or inaccurate.', retry: 'Retry', } satisfies Record diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx index fa3bf9aaf2..ab9e905cf1 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-body.client.spec.tsx @@ -16,15 +16,16 @@ import { LazyExcelBody } from '../src/client/excel/LazyExcelBody.tsx' const props = { content: { kind: 'bytes', data: new Uint8Array([1]) }, limits: Config({}).excel, t: makeTranslate(en), resourceAddress: 'dsh-resource://file/session/s1/book.xlsx' } as ExcelBodyProps const loadedProps = { ...props, format: 'xlsx' } as LoadedExcelBodyProps const value = { sheets: [{ name: 'Budget', celldata: [] }], missingResults: 0 } +const formulaValue = { sheets: [{ name: 'Budget', celldata: [{ r: 0, c: 0, v: { f: '=SUM(1,2)', m: '' } }] }], missingResults: 1 } afterEach(() => { cleanup(); vi.resetAllMocks() }) it('shows loading then a workbook with editing and recalculation disabled', async () => { - mocked.parse.mockResolvedValue({ ...value, missingResults: 1 }) + mocked.parse.mockResolvedValue(formulaValue) const view = render() expect(screen.getByRole('status', { name: en.loading })).toBeDefined() - await screen.findByText(en.summary) - expect(screen.getByText(en.missingResults)).toBeDefined() - expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: value.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) + expect(screen.getByRole('button', { name: en.formulaWarning })).toBeDefined() + expect(mocked.workbook.mock.calls[0]![0]).toMatchObject({ data: formulaValue.sheets, allowEdit: false, forceCalculation: false, showToolbar: false, showSheetTabs: true, lang: 'en', cellContextMenu: ['copy'] }) const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.unmount() expect(signal.aborted).toBe(true) @@ -36,10 +37,10 @@ it('ignores a retired file result and cancels parsing when the bytes change', as const view = render() const signal = mocked.parse.mock.calls[0]![3] as AbortSignal view.rerender() - await screen.findByText(en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) }) expect(signal.aborted).toBe(true) - finish({ ...value, missingResults: 1 }) - await waitFor(() => { expect(screen.queryByText(en.missingResults)).toBeNull() }) + finish(value) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledTimes(1) }) }) it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows localized %s errors and retries explicitly', async (error) => { @@ -48,7 +49,7 @@ it.each(['invalid', 'tooLarge', 'timeout', 'encoding', 'unexpected'])('shows loc const key = error === 'tooLarge' || error === 'timeout' || error === 'encoding' ? error : 'invalid' await screen.findByText(en[key]) fireEvent.click(screen.getByRole('button', { name: en.retry })) - await screen.findByText(en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) expect(mocked.parse).toHaveBeenCalledTimes(2) }) @@ -61,7 +62,8 @@ it('rejects non-byte content without allocating a parser', () => { it.each(['xlsx', 'xls', 'csv', 'tsv'])('loads the spreadsheet chunk for %s', async (format) => { mocked.parse.mockResolvedValue(value) render() - await screen.findByText(format === 'csv' || format === 'tsv' ? en.textSummary : en.summary) + await waitFor(() => { expect(mocked.workbook).toHaveBeenCalledOnce() }) + expect(screen.queryByRole('button', { name: en.formulaWarning })).toBeNull() expect(mocked.parse.mock.calls[0]![1]).toBe(format) }) diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts index 31a1b35c58..edb37f7e9f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-convert.client.spec.ts @@ -4,12 +4,12 @@ import { strToU8, unzipSync, zipSync } from 'fflate' import { describe, expect, it } from 'vitest' import { convertExcel } from '../src/client/excel/convert.ts' import { Config } from '../src/config.ts' -import { excelFixture } from './excel-fixture.ts' +import { excelFixture, meetingMinutesFixture } from './excel-fixture.ts' const limits = Config({}).excel describe('Excel conversion', () => { - it('keeps values, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { + it('keeps values, dates, cached formulas, styles, merges, dimensions, visibility, and frozen headings', async () => { const input = await excelFixture() const { sheets, missingResults } = await convertExcel(input, 'xlsx', limits) expect(input.byteLength).toBeGreaterThan(0) @@ -42,6 +42,17 @@ describe('Excel conversion', () => { expect(missingResults).toBe(1) }) + it('opens workbooks with missing results and Chinese worksheet references', async () => { + const { sheets, missingResults } = await convertExcel(await meetingMinutesFixture(), 'xlsx', limits) + expect(sheets.map(sheet => sheet.name)).toEqual(['会议信息', '会议议程', '决议事项', '行动计划', '待确认问题', '风险与依赖', '统计看板', '填写说明']) + const dashboard = sheets[6]! + const cell = (r: number, c: number) => dashboard.celldata!.find(value => value.r === r && value.c === c)!.v + expect(cell(4, 1)).toMatchObject({ f: '=COUNTIF(行动计划!$G$4:$G$23,$A5)', m: '' }) + expect(cell(4, 2)).toMatchObject({ f: '=IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))', m: '' }) + expect(cell(4, 3)).toMatchObject({ f: '=IFERROR($B5/$B$10,0)', m: '' }) + expect(missingResults).toBe(27) + }) + it('rejects damaged files, byte limits and sparse worksheets that would allocate a large matrix', async () => { await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' }) const input = await excelFixture() diff --git a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts index ef9eefc42b..dcfc93abce 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts @@ -62,3 +62,31 @@ export async function excelHtmlFixture(): Promise> { workbook.addWorksheet('Cached text').getCell('A1').value = { formula: '"cached"', result: excelHtmlText } return new Uint8Array(await workbook.xlsx.writeBuffer()) } + +/** Build a meeting-minutes workbook whose formulas have no cached results. */ +export async function meetingMinutesFixture(): Promise> { + const workbook = new ExcelJS.Workbook() + const info = workbook.addWorksheet('会议信息') + info.getCell('A1').value = '会议纪要' + info.getCell('B11').value = { formula: 'IF(OR(B9="",B10=""),"",ROUND((B10-B9)*1440,0))' } + const agenda = workbook.addWorksheet('会议议程') + agenda.getCell('D16').value = { formula: 'SUM(D4:D15)' } + agenda.getCell('E16').value = { formula: 'SUM(E4:E15)' } + workbook.addWorksheet('决议事项') + const actions = workbook.addWorksheet('行动计划') + for (let row = 4; row <= 23; row += 1) { + actions.getCell(`I${row}`).value = { formula: `IF(AND($E${row}<>"",$E${row}"已完成",$G${row}<>"已取消"),TODAY()-$E${row},"")` } + } + workbook.addWorksheet('待确认问题') + workbook.addWorksheet('风险与依赖') + const dashboard = workbook.addWorksheet('统计看板') + dashboard.getCell('A1').value = '会议纪要 · 统计看板' + dashboard.getCell('A5').value = '未开始' + dashboard.getCell('B5').value = { formula: 'COUNTIF(行动计划!$G$4:$G$23,$A5)' } + dashboard.getCell('C5').value = { formula: 'IF($B5=0,"",REPT("█",MAX(1,ROUND($B5/MAX(1,$B$5)*16,0))))' } + dashboard.getCell('D5').value = { formula: 'IFERROR($B5/$B$10,0)' } + dashboard.getCell('D5').numFmt = '0%' + dashboard.getCell('B10').value = { formula: 'SUM(B5:B9)' } + workbook.addWorksheet('填写说明') + return new Uint8Array(await workbook.xlsx.writeBuffer()) +} diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 0ae42ec777..633a072fa7 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -76,6 +76,8 @@ - Opens without the Office conversion service - Sheets: 季度预算 | 公式与格式; hidden worksheet omitted +- Formula workbooks use a compact warning beside fx; notice rows absent +- Formatted percent copied: 80.0%; date copied: 2026-09-16 - Cached XLOOKUP result copied: 42; typing leaves it unchanged - Formula bar is read-only; PDF body and editing toolbar absent - HTML-looking formulas, text, and cached results stay literal; copying retains text and table cells without executing HTML