fix(preview): compact spreadsheet formula warning
This commit is contained in:
1 parent
767cb38567
commit
ad9f77db04
14 files changed
+122
-46
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -66,7 +66,7 @@ Initial reads, additional pages, and HTML/PDF/image preparation share the ongoin
|
||||
<a id="excel-preview"></a>
|
||||
## 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.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `<img>` 静态
|
||||
<a id="excel-preview"></a>
|
||||
## Excel 预览
|
||||
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少结果时显示提示。表格预览不调用 Office 转换服务。
|
||||
直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。表格预览不调用 Office 转换服务。
|
||||
|
||||
CSV 和 TSV 默认使用表格查看器,也可选择纯文本。两者分别以逗号和制表符分隔字段,支持引号内分隔符、转义引号、多行字段、空字段和不等长行。首行仍作为数据。值保持为字面字符串,包括前导零、日期、布尔值和类似公式的文本。文本文件支持 UTF-8 或带 BOM 的 UTF-16;无效编码会显示转换提示。引号字段格式错误会使表格预览失败,不会静默丢弃数据。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<State>()
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
useEffect(() => {
|
||||
@@ -40,11 +39,15 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
|
||||
<span>{t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}</span>
|
||||
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('retry')}</Button>
|
||||
</div>
|
||||
const hasFormulas = state.value.sheets.some(sheet => sheet.celldata?.some(cell => cell.v?.f !== undefined))
|
||||
return <section className={css.body} data-excel-preview aria-label={t('title')}>
|
||||
<style>{scopedStyles}</style>
|
||||
<p className={css.notice} title={delimited ? undefined : t(format === 'xls' ? 'xlsLimitations' : 'limitations')}>{t(delimited ? 'textSummary' : 'summary')}</p>
|
||||
{state.value.missingResults > 0 && <p className={css.notice} role="status">{t('missingResults')}</p>}
|
||||
<div className={css.workbook}>
|
||||
<div className={`${css.workbook} ${hasFormulas ? css.withFormulaWarning : ''}`}>
|
||||
{hasFormulas && <Tooltip portal label={t('formulaWarning')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.formulaWarning} aria-label={t('formulaWarning')} data-excel-formula-warning>
|
||||
<IconWarningTriangleOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>}
|
||||
<Workbook key={`${attempt}:${t('language')}`} data={state.value.sheets} lang={t('language')}
|
||||
allowEdit={false} showToolbar={false} showFormulaBar showSheetTabs forceCalculation={false}
|
||||
cellContextMenu={['copy']} headerContextMenu={[]} sheetTabContextMenu={[]} filterContextMenu={[]} />
|
||||
|
||||
@@ -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<string, string>
|
||||
|
||||
@@ -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<ExcelPreviewKey, string>
|
||||
|
||||
|
||||
@@ -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(<ExcelBody {...loadedProps} />)
|
||||
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(<ExcelBody {...loadedProps} />)
|
||||
const signal = mocked.parse.mock.calls[0]![3] as AbortSignal
|
||||
view.rerender(<ExcelBody {...loadedProps} content={{ kind: 'bytes', data: new Uint8Array([2]) }} />)
|
||||
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(<LazyExcelBody {...props} resourceAddress={`dsh-resource://file/session/s1/book.${format}`} />)
|
||||
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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -62,3 +62,31 @@ export async function excelHtmlFixture(): Promise<Uint8Array<ArrayBuffer>> {
|
||||
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<Uint8Array<ArrayBuffer>> {
|
||||
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}<TODAY(),$G${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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user