import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import AdmZip from 'adm-zip'; import ExcelJS from 'exceljs'; import { afterEach, describe, expect, it } from 'vitest'; import type { ToolContext } from './core.js'; import { ExcelFileSafetyError } from './excel-file-safety.js'; import { runExcelEditPipeline, type ExcelEditPipelineDependencies, } from './excel-edit-pipeline.js'; import { readSheetDataXml, writeXlsxWithCleanSheetAndCollidingSecondSheet, writeXlsxWithCollidingIdsAndCleanSheet, writeXlsxWithCollidingSharedFormulaIds, writeXlsxWithEmptySharedFormulaMaster, writeXlsxWithOrphanSharedFormulaFollowers, writeXlsxWithSharedFormulaMasterAfterFollowers, writeXlsxWithSharedFormulaBlock, writeXlsxWithSharedFormulaBlocksOnTwoSheets, writeXlsxWithSharedFormulaOf, } from '../../office/__fixtures__/forge-zip.js'; const workspaces: string[] = []; async function workspace(): Promise { const path = await mkdtemp(join(tmpdir(), 'excel-edit-pipeline-')); workspaces.push(path); return path; } async function writeSource(path: string): Promise { mkdirSync(dirname(path), { recursive: true }); const workbook = new ExcelJS.Workbook(); workbook.addWorksheet('Sheet1'); await workbook.xlsx.writeFile(path); } const context = (workspacePath: string): ToolContext => ({ workspacePath, editAllowed: true }); /** 断ったときに元のファイルが 1 バイトも動いていないことを見るための控え。 */ function digestOf(path: string): string { return createHash('sha256').update(readFileSync(path)).digest('hex'); } function recalculation( formulaErrorCodes: Record = {}, ): ExcelEditPipelineDependencies['recalculate'] { return async input => { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(input.inputPath); for (const sheet of workbook.worksheets) { sheet.eachRow(row => row.eachCell(cell => { if (!cell.formula) return; const result = cell.formula === 'SUM(A1:B1)' ? 3 : 2; cell.value = { formula: cell.formula, result }; })); } await workbook.xlsx.writeFile(input.outputPath); return { ok: true, canaryValue: 2, formulaErrorCodes }; }; } afterEach(() => { for (const path of workspaces.splice(0)) rmSync(path, { recursive: true, force: true }); }); describe('runExcelEditPipeline', () => { it('applies merge, color, formula and validates the calculated expectation before commit', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: [ { op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }, { op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: 'SUM(A1:B1)', expect: 3 }, { op: 'merge', sheet: 'Sheet1', range: 'A3:B3' }, { op: 'style_range', sheet: 'Sheet1', range: 'A1:C1', style: { fill: '#FFF2CC', bold: true } }, ], }, context(workspacePath), { recalculate: recalculation() }); expect(result).toMatchObject({ ok: true, phase: 'commit', candidate_path: 'output/report.xlsx', summary: { checked_formula_cells: 1 }, }); const output = join(workspacePath, 'output/report.xlsx'); expect(existsSync(output)).toBe(true); expect(existsSync(join(workspacePath, '.conflict'))).toBe(false); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(output); const sheet = workbook.getWorksheet('Sheet1')!; expect(sheet.getCell('C1').formula).toBe('SUM(A1:B1)'); expect(sheet.getCell('A1').fill).toMatchObject({ fgColor: { argb: 'FFFFF2CC' } }); expect((sheet.model as { merges?: string[] }).merges).toContain('A3:B3'); }); it('does not publish a candidate when recalculation reports a formula error', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: [ { op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '"text"+1' }, ], }, context(workspacePath), { recalculate: recalculation({ 'Sheet1!C1': 519 }), }); expect(result).toMatchObject({ ok: false, phase: 'calculation', candidate_path: null, violations: [expect.objectContaining({ code: 'FORMULA_ERROR', sheet: 'Sheet1', cell: 'C1' })], }); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); }); /** * 実行時例外を「ファイルが壊れている」と診断しない(#1067 診断 1 件目)。 * * ここへ来る時点で元ファイルは package 段階の検査を通っている。外部プロセスや * ライブラリが落ちただけの例外を PACKAGE_INVALID にすると、正常なブックが * 壊れていると報告される。 */ it('reports an unexpected runtime failure as INTERNAL_ERROR, not a broken workbook', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: [ { op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '1+1', expect: 2 }, ], }, context(workspacePath), { recalculate: async () => { throw new Error('spawn soffice ENOMEM'); }, }); expect(result.ok).toBe(false); expect(result.violations).toHaveLength(1); const [violation] = result.violations; expect(violation.code).toBe('INTERNAL_ERROR'); expect(violation.code).not.toBe('PACKAGE_INVALID'); // 生の例外文言だけを返さない(何が起きたのかを添えて返す)。 expect(violation.message).toContain('内部エラー'); expect(violation.message).toContain('spawn soffice ENOMEM'); expect(violation.message).not.toBe('spawn soffice ENOMEM'); // 元のブックは無事で、候補も残らない。 expect(existsSync(join(workspacePath, 'input/source.xlsx'))).toBe(true); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); }); /** * 反対側も固定する。理由の分かっている失敗(ExcelFileSafetyError)は、 * INTERNAL_ERROR に丸めず、その診断のまま返す(#1067 レビュー P2-3)。 */ it('keeps a known file-safety failure with its own diagnosis', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: [ { op: 'set_formula', sheet: 'Sheet1', cell: 'C1', formula: '1+1', expect: 2 }, ], }, context(workspacePath), { recalculate: async () => { throw new ExcelFileSafetyError('PACKAGE_INVALID', '壊れたZIP/OOXMLパッケージです'); }, }); expect(result.ok).toBe(false); expect(result.violations).toHaveLength(1); const [violation] = result.violations; expect(violation.code).toBe('PACKAGE_INVALID'); expect(violation.code).not.toBe('INTERNAL_ERROR'); expect(violation.message).toBe('壊れたZIP/OOXMLパッケージです'); expect(violation.message).not.toContain('内部エラー'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); }); /** * `operations` の経路は共通の関所を通らない(手前で分岐して返る)。展開後 * サイズの上限が設定キーを見ずに 200MB 固定だったのを、同じキーへ繋いだ * (#1067 レビュー P2-4)。同じファイルで、設定を絞ると断り、既定なら通る。 */ it('honours officePptxMaxUncompressedMb on the operations path', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/source.xlsx'); await writeSource(source); const zip = new AdmZip(source); zip.addFile('docProps/thumbnail.bin', Buffer.alloc(2 * 1024 * 1024, 0)); zip.writeZip(source); const request = { path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }], }; const rejected = await runExcelEditPipeline( request, { workspacePath, editAllowed: true, toolsConfig: { officePptxMaxUncompressedMb: 1 } }, { recalculate: recalculation() }, ); expect(rejected.ok).toBe(false); expect(rejected.violations[0]?.code).toBe('PACKAGE_INVALID'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); // 同じファイルでも、既定(200MB)なら通る=断ったのは設定キーの効果。 const accepted = await runExcelEditPipeline( request, context(workspacePath), { recalculate: recalculation() }, ); expect(accepted.ok).toBe(true); }); /** * 項目数の上限も同じ設定キーで動く。共通の関所とこちらで値が食い違うと、 * 同じファイルが経路によって通ったり断られたりする(#1067 レビュー P2-2)。 */ it('honours officeMaxEntries on the operations path', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/source.xlsx'); await writeSource(source); const request = { path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1:B1', values: [[1, 2]] }], }; const rejected = await runExcelEditPipeline( request, { workspacePath, editAllowed: true, toolsConfig: { officeMaxEntries: 1 } }, { recalculate: recalculation() }, ); expect(rejected.ok).toBe(false); expect(rejected.violations[0]?.code).toBe('PACKAGE_INVALID'); expect(rejected.violations[0]?.message).toContain('部品数'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); // 同じファイルでも、既定(10000 件)なら通る=断ったのは設定キーの効果。 const accepted = await runExcelEditPipeline( request, context(workspacePath), { recalculate: recalculation() }, ); expect(accepted.ok).toBe(true); }); it('fully validates dry_run but leaves no output file', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/report.xlsx', dry_run: true, operations: [ { op: 'set_formula', sheet: 'Sheet1', cell: 'A1', formula: '1+1', expect: 2 }, ], }, context(workspacePath), { recalculate: recalculation() }); expect(result).toMatchObject({ ok: true, phase: 'commit', candidate_path: null }); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); }); it('compiles gantt holiday and progress colors after applying data operations', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/gantt.xlsx', operations: [ { op: 'set_values', sheet: 'Sheet1', range: 'D1:H1', values: [['2026-08-07', '2026-08-08', '2026-08-09', '2026-08-10', '2026-08-11']] }, { op: 'set_values', sheet: 'Sheet1', range: 'A2:C2', values: [['2026-08-07', '2026-08-11', 0.5]] }, ], gantt: { sheet: 'Sheet1', timeline_header: 'D1:H1', task_rows: '2', start_column: 'A', end_column: 'B', progress_column: 'C', progress_scale: 'fraction', holidays: ['2026-08-10'], roles: { weekend: { fill: '#EEEEEE' }, holiday: { fill: '#FFCCCC' }, planned: { fill: '#99CCFF' }, completed: { fill: '#66CC66' }, }, }, }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok, JSON.stringify(result)).toBe(true); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(join(workspacePath, 'output/gantt.xlsx')); const sheet = workbook.getWorksheet('Sheet1')!; expect(sheet.getCell('G1').fill).toMatchObject({ fgColor: { argb: 'FFFFCCCC' } }); expect(sheet.getCell('D2').fill).toMatchObject({ fgColor: { argb: 'FF66CC66' } }); expect(sheet.getCell('H2').fill).toMatchObject({ fgColor: { argb: 'FF99CCFF' } }); }); it('counts user and gantt operations against one combined budget', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/source.xlsx'); await writeSource(source); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(source); const sheet = workbook.getWorksheet('Sheet1')!; sheet.getCell('D1').value = '2026-08-08'; sheet.getCell('A2').value = '2026-08-08'; sheet.getCell('B2').value = '2026-08-08'; sheet.getCell('C2').value = 0.5; await workbook.xlsx.writeFile(source); const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/too-many.xlsx', operations: Array.from({ length: 500 }, () => ({ op: 'freeze_panes', sheet: 'Sheet1', rows: 1, columns: 1, })), gantt: { sheet: 'Sheet1', timeline_header: 'D1:D1', task_rows: '2', start_column: 'A', end_column: 'B', progress_column: 'C', progress_scale: 'fraction', holidays: [], roles: { weekend: { fill: '#EEEEEE' }, holiday: { fill: '#FFCCCC' }, planned: { fill: '#99CCFF' }, completed: { fill: '#66CC66' }, }, }, }, context(workspacePath), { recalculate: recalculation() }); expect(result).toMatchObject({ ok: false, phase: 'normalize', violations: [expect.objectContaining({ code: 'INPUT_INVALID' })], }); expect(existsSync(join(workspacePath, 'output/too-many.xlsx'))).toBe(false); }); /** * コピーした式のおおもと(#1351 段 2)。 * * ExcelPatch は読んで書き直す作りなので、**もとのファイルにかたまりがあれば * そのまま残る**。段 1 まではおおもとへの書き込みを断っていた。段 2 では、 * 追随するセルへ式を配ってから編集を通す。**画面の保存側だけ通せるように * すると、こちらが断ったままの抜け道になる。** */ describe('コピーした式のおおもと', () => { it('おおもとに式を書く指示は通り、追随するセルは元の式を全文で持つ', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/shared.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaBlock(source); const result = await runExcelEditPipeline({ path: 'input/shared.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_formula', sheet: 'Sheet1', cell: 'C2', formula: 'A2+B2' }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(true); const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx')); // 頼んだのは C2 だけ。残りは「おおもとと同じ式」ではなく自分の式になる。 expect(sheetXml).toContain('A2+B2'); expect(sheetXml).toContain('A3*B3'); expect(sheetXml).toContain('A4*B4'); expect(sheetXml).not.toContain('t="shared"'); }); it('おおもとに値を書く指示も通り、追随するセルの式は残る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/shared.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaBlock(source); const result = await runExcelEditPipeline({ path: 'input/shared.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(true); const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx')); expect(sheetXml).toContain('A3*B3'); expect(sheetXml).toContain('A4*B4'); }); /** 訳せない形(列をまるごと指す参照)は、段 1 と同じく理由を添えて断る。 */ it('訳せない式のおおもとは、理由の分かる形で断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/shared.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaOf(source, 'SUM(A:A)*B2'); const result = await runExcelEditPipeline({ path: 'input/shared.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result).toMatchObject({ ok: false, violations: [expect.objectContaining({ code: 'SHARED_FORMULA_MASTER', sheet: 'Sheet1', cell: 'C2', })], }); expect(result.violations[0]!.message).toContain('C2:C4'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); }); /** * 同じ番号(`si`)を名乗るかたまりが 2 つある書類(#1378)。 * * この経路はブックを exceljs で丸ごと読み込んで丸ごと書き戻す。exceljs はこの * 並びを「あとに見たおおもとが勝つ」で読み、Excel / LibreOffice は「先に見た * おおもとが勝つ」で読む。だから**書き込み先がどこであっても**、触っていない * C のかたまりに E のかたまりの式が流れ込む(LibreOffice で計算し直すと * C3 が 33 から 90、E3 が 40 から 630 に変わる)。 * * **関係の無いマスへの書き込みでも断ること。**#1378 の穴はここだった。 * おおもとを書くときだけ断っていたので、`A1` を書く指示は素通りしていた。 */ it('番号がぶつかった書類では、無関係なマス(A1)への書き込みも断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/collide.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithCollidingSharedFormulaIds(source); const before = digestOf(source); const result = await runExcelEditPipeline({ path: 'input/collide.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(false); expect(result.violations.map(item => [item.code, item.sheet, item.cell])) .toEqual([ ['SHARED_FORMULA_MASTER', 'Sheet1', 'C2'], ['SHARED_FORMULA_MASTER', 'Sheet1', 'E2'], ]); // 「おおもとに書こうとしたから断った」と読める文面にしない。書いたのは A1 で、 // 利用者はどのかたまりにも触っていない。 expect(result.violations[0]!.message).toContain('どのマスを書き換えても'); // この並びは `edits` なら通る(`xlsx-shared-formula.test.ts` で裏取り済み)。 // 案内文もそう言い切っている。通らない並びと同じ文面にしてはいけない。 expect(result.violations[0]!.message) .toContain('edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります'); expect(digestOf(source)).toBe(before); }); /** 断ったら、候補のファイルも残さない。書きかけを拾わせない。 */ it('番号がぶつかった書類を断ったとき、出力ファイルは作られない', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/collide.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithCollidingSharedFormulaIds(source); const result = await runExcelEditPipeline({ path: 'input/collide.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(false); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); }); /** * **書き換えるシートと壊れるシートは別**(#1378)。 * * 丸ごと書き戻す経路なので、`Sheet2` を書く指示でも `Sheet1` のかたまりが * 化ける。書き込み先のシートだけを見る関所では捕まらない。 * * `Sheet2` も `si="0"` を使っている。シートをまたいだ番号を「ぶつかっている」と * 読む関所なら、この材料で断りすぎが出る。 */ it('別のシートを編集する指示でも、番号のぶつかったシートを理由に断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/collide.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithCollidingIdsAndCleanSheet(source); const before = digestOf(source); const result = await runExcelEditPipeline({ path: 'input/collide.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet2', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(false); // 断る理由は Sheet1 のかたまりだけ。Sheet2 は普通のかたまりなので挙げない。 expect(result.violations.map(item => [item.sheet, item.cell])) .toEqual([['Sheet1', 'C2'], ['Sheet1', 'E2']]); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); expect(digestOf(source)).toBe(before); }); /** * **壊れているのは 2 枚目のシートだけ**(#1378)。 * * 1 枚目は `si="0"` のかたまりが 1 つあるだけの普通のシートで、いくら見ても * 何も出ない。関所が 1 枚目で歩みを止めていると、この書類は素通りする。 * 上の「別のシートを編集する指示でも〜」は壊れている側が 1 枚目なので、 * 1 枚目しか見ない関所でも断れてしまい、この穴を塞げない。 * * 書き込み先は普通のほうの `Sheet1`。**利用者が触っているシートには何の問題も * 無い**のに、丸ごと書き戻す経路では `Sheet2` のかたまりが化ける。 */ it('2 枚目のシートにだけ番号のぶつかったかたまりがある書類も断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/second-sheet.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithCleanSheetAndCollidingSecondSheet(source); const before = digestOf(source); const result = await runExcelEditPipeline({ path: 'input/second-sheet.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(false); // 断る理由は Sheet2 のかたまりだけ。Sheet1 は普通のかたまりなので挙げない。 expect(result.violations.map(item => [item.code, item.sheet, item.cell])) .toEqual([ ['SHARED_FORMULA_MASTER', 'Sheet2', 'C2'], ['SHARED_FORMULA_MASTER', 'Sheet2', 'E2'], ]); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); expect(digestOf(source)).toBe(before); }); /** * 文面の無いおおもとを持つ書類(#1376)。 * * 以前は exceljs が式を平行移動しようとして `TypeError` を投げ、`normalize` 段の * `INTERNAL_ERROR` になっていた。符号の意味(こちら側の実行時例外)と実態 * (ファイル側が原因)が食い違うので、利用者は原因にたどり着けない。 * **入口で理由を添えて断る。** */ it('文面の無いおおもとを持つ書類は、内部エラーではなく理由を添えて断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/empty-master.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithEmptySharedFormulaMaster(source); const before = digestOf(source); const result = await runExcelEditPipeline({ path: 'input/empty-master.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result).toMatchObject({ ok: false, violations: [expect.objectContaining({ code: 'SHARED_FORMULA_MASTER', sheet: 'Sheet1', cell: 'C2', })], }); expect(result.violations.some(item => item.code === 'INTERNAL_ERROR')).toBe(false); expect(result.violations[0]!.message).toContain('配るはずの式の文面がありません'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); expect(digestOf(source)).toBe(before); }); /** * おおもとがただの値に置き換わり、追随側だけが残った書類(#1378 の 3 つ目の形)。 * * これも成功として返っていた。書き出されるのは中身の無い式(``)で、 * LibreOffice で開くと `Err:520` になる(実測)。 */ it('おおもとの消えた追随マスが残っている書類も断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/orphan.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithOrphanSharedFormulaFollowers(source); const before = digestOf(source); const result = await runExcelEditPipeline({ path: 'input/orphan.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(false); expect(result.violations.map(item => [item.code, item.cell])) .toEqual([['SHARED_FORMULA_MASTER', 'C3'], ['SHARED_FORMULA_MASTER', 'C4']]); expect(result.violations[0]!.message).toContain('Err:520'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); expect(digestOf(source)).toBe(before); }); /** * 追随マスが**おおもとより前**に並んでいる書類(#1378 レビュー P1-1)。 * * 範囲(`ref`)は追随マスを含んでいるので、範囲だけを見る判定はこの並びを * 通してしまう。ところが exceljs は読みながら採番表を作るので、まだ読んで * いない番号を引いた C2・C3 は持ち主が `undefined` になり、書き出しで * 中身の無い式(``)になる。**成功として返ったうえで値が消えていた。** */ it('おおもとが追随マスより後ろに並んだ書類も断る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/master-after.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaMasterAfterFollowers(source); const before = digestOf(source); const result = await runExcelEditPipeline({ path: 'input/master-after.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(false); expect(result.violations.map(item => [item.code, item.sheet, item.cell])) .toEqual([ ['SHARED_FORMULA_MASTER', 'Sheet1', 'C2'], ['SHARED_FORMULA_MASTER', 'Sheet1', 'C3'], ]); // 「おおもとがどこにも無い」書類とは症状が違う。文面も分ける。 expect(result.violations[0]!.message).toContain('C4'); expect(result.violations[0]!.message).toContain('後ろ'); expect(result.violations[0]!.message).toContain('Err:520'); // この並びは `edits` なら通る(`xlsx-shared-formula.test.ts` で裏取り済み)。 expect(result.violations[0]!.message) .toContain('edits(値・数式の差し替え)で頼めば、この書き方のままでも通ります'); expect(existsSync(join(workspacePath, 'output/report.xlsx'))).toBe(false); expect(digestOf(source)).toBe(before); }); /** * **普通の複数シートのブックは通す**(#1378)。 * * 共有式の番号はシートごとに閉じているので、普通のブックは素で「どのシートも * `si="0"`」になる。入口の関所が 1 冊まとめて見ていると、この形が全部断られる。 * 断りすぎに倒したときに気づくための検査。 */ it('どのシートも si=0 を使う普通のブックは通る', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/two-sheets.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaBlocksOnTwoSheets(source); const result = await runExcelEditPipeline({ path: 'input/two-sheets.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet2', range: 'A1', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result).toMatchObject({ ok: true, phase: 'commit' }); // かたまりはどちらのシートでも無傷のまま残る。番地はシートごとに違う // (同じ番地だと、2 枚を 1 枚として見る誤りが潰れて見えなくなる)。 const out = join(workspacePath, 'output/report.xlsx'); expect(readSheetDataXml(out, 'xl/worksheets/sheet1.xml')) .toContain('A2*B2'); expect(readSheetDataXml(out, 'xl/worksheets/sheet2.xml')) .toContain('A2*B2'); }); it('追随側のセルと、本体への書式だけの指示は通す', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/shared.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaBlock(source); const result = await runExcelEditPipeline({ path: 'input/shared.xlsx', output_path: 'output/report.xlsx', operations: [ { op: 'set_values', sheet: 'Sheet1', range: 'C3', values: }, { op: 'style_range', sheet: 'Sheet1', range: 'C2', style: { bold: true } }, ], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok).toBe(true); const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx')); expect(sheetXml).toContain('A2*B2'); }); }); }); describe('配列数式の保護', () => { it('本体の上書きを具体的な理由で断り、元ファイルを維持する', async () => { const root = await workspace(); const path = join(root, 'array.xlsx'); const book = new ExcelJS.Workbook(); const sheet = book.addWorksheet('Sheet1'); sheet.getCell('A1').value = { formula: 'B1:B3*2', shareType: 'array', ref: 'A1:A3', result: 2 }; sheet.getCell('A2').value = 4; sheet.getCell('A3').value = 6; await book.xlsx.writeFile(path); const before = digestOf(path); const result = await runExcelEditPipeline({ path: 'array.xlsx', output_path: 'output/array.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'A1', values: }], }, context(root)); expect(result.violations).toEqual(expect.arrayContaining([expect.objectContaining({ code: 'UNSUPPORTED_WORKBOOK_FEATURE', message: expect.stringContaining('array formula'), })])); expect(digestOf(path)).toBe(before); expect(existsSync(join(root, 'output/array.xlsx'))).toBe(false); }); }); describe('数式の計算済みゼロ値 (#1373)', () => { it('再計算した通常数式の数値ゼロを期待値として検証し、他の結果型も維持する', async () => { const workspacePath = await workspace(); await writeSource(join(workspacePath, 'input/source.xlsx')); const expected = [0, 'zero', true, 7] as const; const result = await runExcelEditPipeline({ path: 'input/source.xlsx', output_path: 'output/report.xlsx', operations: expected.map((value, index) => ({ op: 'set_formula', sheet: 'Sheet1', cell: `A${index + 1}`, formula: `${index + 1}`, expect: value, })), }, context(workspacePath), { recalculate: async input => { const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(input.inputPath); for (const sheet of workbook.worksheets) { sheet.eachRow(row => row.eachCell(cell => { if (!cell.formula) return; const rowIndex = Number.parseInt(cell.address.slice(1), 10) - 1; const cached = sheet.name === 'Sheet1' ? expected[rowIndex] : 2; cell.value = { formula: cell.formula, result: cached }; })); } await workbook.xlsx.writeFile(input.outputPath); return { ok: true, canaryValue: 2, formulaErrorCodes: {} }; }, }); expect(result.ok, JSON.stringify(result)).toBe(true); }); it('共有数式を実体化しても追随セルの数値ゼロを残す', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/shared.xlsx'); mkdirSync(dirname(source), { recursive: true }); await writeXlsxWithSharedFormulaOf(source, 'A2*B2'); const result = await runExcelEditPipeline({ path: 'input/shared.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok, JSON.stringify(result)).toBe(true); const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx')); expect(sheetXml).toContain('A3*B30'); expect(sheetXml).toContain('A4*B40'); }); }); describe('共有数式の偽値キャッシュ (#1373)', () => { it('実体化した追随セルの false を真偽値のまま残す', async () => { const workspacePath = await workspace(); const source = join(workspacePath, 'input/shared-false.xlsx'); mkdirSync(dirname(source), { recursive: true }); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('Sheet1'); for (let row = 2; row <= 4; row += 1) { sheet.getCell(`A${row}`).value = row; sheet.getCell(`B${row}`).value = row * 10; } sheet.getCell('C2').value = { formula: 'A2*B2', shareType: 'shared', ref: 'C2:C4', result: 40, }; sheet.getCell('C3').value = { sharedFormula: 'C2', result: false }; sheet.getCell('C4').value = { sharedFormula: 'C2', result: 160 }; await workbook.xlsx.writeFile(source); const result = await runExcelEditPipeline({ path: 'input/shared-false.xlsx', output_path: 'output/report.xlsx', operations: [{ op: 'set_values', sheet: 'Sheet1', range: 'C2', values: }], }, context(workspacePath), { recalculate: recalculation() }); expect(result.ok, JSON.stringify(result)).toBe(true); const sheetXml = readSheetDataXml(join(workspacePath, 'output/report.xlsx')); expect(sheetXml).toContain('A3*B30'); expect(sheetXml).toContain('A4*B4160'); }); });